LoginSignup
1
1

More than 1 year has passed since last update.

React + Redux Toolkit の構成でcognitoの認証をやってみたメモ

Last updated at Posted at 2022-08-04

概要

aws-amplifyの認証をシンプルに実装してみたメモ。
cognitoは前回の疎通確認したメモで作成したものに手を加えて使う。

ソースコード(client)
ソースコード(api)
ソースコード(cdk)
cognitユーザプール作成コマンド

環境

  • Windows10
  • gitbash
  • aws-cli/2.7.21 Python/3.9.11 Windows/10 exe/AMD64 prompt/off
  • cdk v2.35
  • node v16.16.0

変更点

ユーザプールクライアント

amazon-cognito-identity-jsではクライアントシークレットをサポートしないので false に設定

When creating the App, the generate client secret box must be unchecked because the JavaScript SDK doesn't support apps that have a client secret.

cdk/lib/LambdaWithCognitoStack.ts
    const userPoolClient = userPool.addClient('client', {
      oAuth: {
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],

        callbackUrls: props.callbackUrls,
        logoutUrls: props.logoutUrls,
        flows: { authorizationCodeGrant: true },

      },
+      generateSecret: false,
      idTokenValidity: Duration.minutes(5)
    })

ApiGateway

ブラウザからfetchAPIでアクセスするため、ヘッダとクレデンシャルの設定を追加で行う。
(参考:Access-Control-Allow-Credentials)

cdk/lib/LambdaWithCognitoStack.ts
    const httpApi = new apigw.HttpApi(this, `${props.projectId}-apigw`, {
      createDefaultStage: false,
      corsPreflight: {
        allowOrigins: '*',
        allowMethods: [apigw.CorsHttpMethod.ANY],
+        allowHeaders: ['authorization', ' Content-Type', 'X-Api-Key',],
+        allowCredentials: true
      },
    })

    httpApi.addRoutes({
      methods: [apigw.HttpMethod.GET],
      path: '/scenario',
      integration: new intg.HttpLambdaIntegration(`${props.projectId}-scenarioIntegration`,handler,),
      authorizer,
    })

Lambdaの修正

cors用のヘッダを追加

src/handler/api/hello.ts
import { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import { format } from 'date-fns';
import { formatDate } from '@/common/index';


+ const corsHeaders = {
+   'Content-type': 'application/json',
+   'Access-Control-Allow-Origin': '*'
+ }

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const datetime = formatDate(new Date(event.requestContext.timeEpoch));

  return {
    'statusCode': 200,
+     headers: corsHeaders,
    'body': JSON.stringify({
      message: `hello world. ${datetime} = ${event.requestContext.timeEpoch}. now: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss.SSS')}`,
    })
  };
};

ユーザプールの作成

今回はawsconsoleではなく、awscliから作成を行った。(参考)

#!/bin/bash

COGNITO_CLIENT_ID=hoge
COGNITO_USER_NAME=test@example.com
COGNITO_USER_PASSWORD=SamplePassw0rd!
COGNITO_UER_MAIL_ADDRESS=test@example.com
COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxx

# ステータスが FORCE_CHANGE_PASSWORD のユーザを作成
# --message-action SUPPRESS : メッセージをメールアドレスに送信しない
aws cognito-idp admin-create-user  \
--user-pool-id $COGNITO_USER_POOL_ID \
--username  $COGNITO_USER_NAME \
--message-action SUPPRESS \
--user-attributes Name=email,Value=$COGNITO_UER_MAIL_ADDRESS

# オプション --permanent を指定することで、パスワードは恒久的なパスワードとなりステータスが CONFIRMED となる
aws cognito-idp admin-set-user-password \
--user-pool-id $COGNITO_USER_POOL_ID \
--username $COGNITO_USER_NAME \
--password $COGNITO_USER_PASSWORD \
--permanent 

クライアントからの問い合わせ

基本は、Amplify を使わず React で AWS Cognito 認証を使うの通り。

サインイン

client/src/store/slices/auth.ts
import {
  createAsyncThunk,
  createSlice,
  SerializedError,
} from '@reduxjs/toolkit'
import { Auth } from 'aws-amplify'

interface AuthState {
  username?: string
  authenticated: boolean
  error?: SerializedError
}

const initialState: AuthState = {
  username: undefined,
  authenticated: false,
  error: undefined,
}



export const signIn = createAsyncThunk<AuthState, { username: string, password: string }>(
  'signIn',
  async ({ username, password }, thunkAPI) => {
    try {
      const result = await Auth.signIn(username, password)
      return { username: result.username, authenticated: true }
    } catch (error: any) {
      return thunkAPI.rejectWithValue({ error: error.message })
    }
  },
)

export const signOut = createAsyncThunk('logout', async (_, thunkAPI) => {
  try {
    await Auth.signOut()
  } catch (error: any) {
    return thunkAPI.rejectWithValue({ error: error.message })
  }
})

export const initUser = createAsyncThunk<AuthState>(
  'initUser',
  async (req, thunkAPI) => {
    try {
      const result = await Auth.currentAuthenticatedUser()
      return { username: result.username, authenticated: true }
    } catch (error: any) {
      return thunkAPI.rejectWithValue({ error: error.message })
    }
  },
)


export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(signIn.fulfilled, (state, action) => {
      state.username = action.payload.username
      state.authenticated = true
    })
    builder.addCase(signIn.rejected, (state, action) => {
      state.error = action.error
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(signOut.fulfilled, (state) => {
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(signOut.rejected, (state, action) => {
      state.error = action.error
      state.authenticated = false
      state.username = initialState.username
    })
    builder.addCase(initUser.fulfilled, (state, action) => {
      state.authenticated = true
      state.username = action.payload.username
    })
  }
})
client/src/store/index.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'
import { authencicatedApi } from './rtkQuery/api'
import { authSlice, initUser } from './slices/auth'

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    [authencicatedApi.reducerPath]: authencicatedApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(authencicatedApi.middleware),
})

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>

store.dispatch(initUser())
client/components/pages/Login.tsx
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch } from '@/store/hooks'
import { signIn } from '@/store/slices/auth'

const Login: React.FC = () => {
  const navigate = useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const dispatch = useAppDispatch()

  const executeSignIn = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const result = await dispatch(signIn({ username, password }))
    if (result.meta.requestStatus !== 'fulfilled') {
      alert('認証に失敗しました。')
      return
    }
    navigate({ pathname: '/admin/top' })
  }

  return (
    <form noValidate onSubmit={executeSignIn}>
      <div>
        <label htmlFor="username">メールアドレス: </label>
        <input
          id="username"
          type="email"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="password">パスワード: </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <button type="submit">ログイン</button>
    </form>
  )
}
export default Login
client/src/router/context/PrivateRoute.tsx
import React from 'react'
import { Navigate, Outlet, RouteProps } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks'
import { isUserAuthenticatedSelector } from '@/store/selectors/auth'

export const PrivateRoute: React.FC<RouteProps> = ({ element: _, ...rest }) => {
  const isAuthenticated = useAppSelector(isUserAuthenticatedSelector)
  if (!isAuthenticated) {
    return <Navigate to="/login" />
  }
  const isAdminPath = rest && rest.path && rest.path.indexOf('/admin') >= 0
  if (isAdminPath) {
    return <Navigate to="/admin" />
  }
  return <Outlet />
}

ログイン状態に応じたページ遷移制御

client/src/route/RoutesApp.tsx
import React from 'react'
import { Route, Routes } from 'react-router-dom'
import Login from '@/components/Pages/Login'
import Redirect from '@/components/Pages/Redirect'
import Test from '@/components/Pages/Test'
import Top from '@/components/Pages/Top'
import { PrivateRoute } from './context/PriveteRoute'
import { PublicRoute } from './context/PublicRoute'

const App: React.FC = () => {
  return (
    <Routes>
      <Route path="/admin" element={<PrivateRoute />}>
        <Route path="/admin/top" element={<Top />} />
      </Route>

      <Route element={<PublicRoute />}>
        <Route path="/" element={<Redirect />} />
        <Route path="/login" element={<Login />} />
        <Route path="/test" element={<Test />} />
      </Route>
    </Routes>
  )
}

export default App
client/src/router/context/PublicRoute.tsx
import React from 'react'
import { Outlet, RouteProps } from 'react-router-dom'

export const PublicRoute: React.FC<RouteProps> = () => {
  return <Outlet />
}
client/src/router/context/PrivateRoute.tsx
import React from 'react'
import { Navigate, Outlet, RouteProps } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks'
import { isUserAuthenticatedSelector } from '@/store/selectors/auth'

export const PrivateRoute: React.FC<RouteProps> = ({ element: _, ...rest }) => {
  const isAuthenticated = useAppSelector(isUserAuthenticatedSelector)
  if (!isAuthenticated) {
    return <Navigate to="/login" />
  }

  return <Outlet />
}

RTK Query

リフレッシュトークンは、Auth.currentSession()でよしなにやってくれるので、明示的な指定は必要ない。

(参考:はじめてのCognito: aws-amplify のAuthでリフレッシュトークンしてる処理を追ってみた)

client/src/store/rtkQuery/api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Auth } from 'aws-amplify'

export const authencicatedApi = createApi({
  reducerPath: 'authencicatedApi',
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_APP_API_DOMAIN,
    prepareHeaders: async (headers, _) => {
      const session = await Auth.currentSession()
      const token = session?.getIdToken().getJwtToken()
      headers.set('authorization', token)
      return headers
    },
    mode: 'cors', // Fetch API では mode cors を設定する必要がある
  }),
  endpoints: (builder) => ({
    getScenario: builder.query<{ message: string }, void>({
      query: () => `api/scenario`,
    }),
  }),
})
export const { useGetScenarioQuery } = authencicatedApi

参考

Amplify を使わず React で AWS Cognito 認証を使う
AWS cognito と React でログインを実装する
AWS CDKv2 で Cognito 認証された API Gateway を構築して swaggerui で疎通確認したメモ
AWS Cli で Cognito に CONFIRMED ユーザーを作成し、emialを設定する

調査

Amplify UI を使って React アプリに Amazon Cognito の認証フォームを実装する
はじめてのcognito
Vite のプロジェクトに Amplify の設定を適用する方法
Amplify で既存 Cognito と 既存 APIGateway を使ってみる
Amplify プロジェクトに既存の Cognito ユーザープールと ID プールを使用する
Amplify UI
ViteをVue+Amplifyプロジェクトに導入する時に困ったこと&解決策

REST API リソースの CORS を有効にする
cdk
API Gateway コンソールを使用してリソースで CORS を有効にする
なんとなく CORS がわかる...はもう終わりにする。
v2 cdk
allow credentias

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1