Hyunseok
프로그래밍/개인홈페이지 [Nextjs] AWS Cognito 와 NextAuth 를 이용하여 서버사이드 로그인 / 회원가입을 구현하자
2024. 9. 8. 16:23

발단

 

요즘 취미는 항상 프로젝트 시작 할 때마다 생성하는.. 반복되는 템플릿을 정형화 시키기위해 .. 

(궁극적으로는 이 템플릿으로 가능한한 시작 할 예정)

 

template이라는 레포지토리를 파서 계속 작성하고있다 

 

SQL부터 시작해서

서버리스를 위한 sqlite

더 나아가 조금 더 확실한 관리를 위한 Supabase

마지막으로는 결국 supabase를 피해서 aws의 congnito까지 오게 되었다 ..

 

여정이야 어찌되었던 일단 구현부터 시작해보자

 

aws 설정

먼저 cognito로 들어가주자 

https://aws.amazon.com/ko/cognito/

 

Cognito | 계정 동기화 | Amazon Web Services

위험 기반 적응형 인증, 손상된 보안 인증 정보 모니터링, 보안 지표와 같은 고급 보안 기능을 추가하여 규정 준수 및 데이터 레지던시 요구 사항을 지원할 수 있습니다.

aws.amazon.com

 

 

사용자 풀이 없다면 사용자 풀을 하나 만들어주자 

 

연동자격공급자는 선택할 필요없고

 

로그인 옵션은 사용자명과 이메일

MFA는 사용하지 않을 거라서 MFA는 해제

굳이 SES는 쓰지 않아도 괜찮으니 'Cognito를 사용하여 이메일 전송'

(어차피 lambda로 인증은 무시 시킬 것이다)

퍼블릭 클라이언트 선택 후 유저풀을 하나 작성한다

 

나머지 선택할 부분은 굳이 없는 것 같고 위에서 언급된 내용만 설정해주고

다음다음 누르다보면 유저풀이 생성된다

 

생성된 사용자 풀을 선택해서 들어가면 사용자풀ID가 있다. 어따가 잘 적어주자

 

그리고

 

 

앱통합을 눌러서 맨 아래로 가면 아까 생성한 퍼블릭 클라이언트가 있다

우측에 클라이언트 ID 도 어디다 잘 적어두자

 

 

다음으로 사용자 풀 속성 > 트리거 추가

 

사전가입(pre-sign up trigger) 트리거에 다음과 같은 람다를 추가해준다

 

export const handler = async (event) => {
    event.response.autoConfirmUser = true;
    
    if (event.request.userAttributes.hasOwnProperty('email')) {
        event.response.autoVerifyEmail = true;
    }

    if (event.request.userAttributes.hasOwnProperty('phone_number')) {
        event.response.autoVerifyPhone = true;
    }

    return event;
};

 

 

 

그럼 어느정도 aws세팅은 끝났다 

 

API 작성

 

기본적으로 next auth (auth.js)

 

Auth.js | Authentication for the Web

Authentication for the Web

authjs.dev

그리고 next는 당연히 세팅되어있어야한다

 

14버전 말기, 15버전이 나오냐마나 하는 시점이니.. 그냥 가볍게 page라우터를 건너서 app라우터로 진행한다

 

추가적으로 cognito를 사용할 수 있게끔 작성된 아래의 라이브러리를 npm로 깔아주자

 

amazon-cognito-identity-js

Amazon Cognito Identity Provider JavaScript SDK. Latest version: 6.3.12, last published: 6 months ago. Start using amazon-cognito-identity-js in your project by running `npm i amazon-cognito-identity-js`. There are 639 other projects in the npm registry us

www.npmjs.com

 

 

대충 필요 한 것은 다 깔았고, 일단 .env에 내가 아까 기록해두자 하던 값들을 넣어준다

 

/* AWS 목차에서 기록해둔 두 값들 */
COGNITO_USER_POOL_ID=
COGNITO_CLIENT_ID=
NEXTAUTH_SECRET=

 

그 다음으로는 cognito를 사용할 provider를 작성해야한다

 

손으로 다 짤까 했는데 비슷한 내용을 누군가가 이미 다 짜놨다

https://github.com/Imjurney/next-auth-cognito

 

GitHub - Imjurney/next-auth-cognito: next-auth와 AWS cognito를 통합해 서버사이드(Server Side)에서 인증 처리를

next-auth와 AWS cognito를 통합해 서버사이드(Server Side)에서 인증 처리를 하는 방법 - Imjurney/next-auth-cognito

github.com

 

위의 amazon-cognito-identity-js의 case에 있는 부분을 약간 개량해서 써놓았는데 여하튼, 다음과 같이 먼저 작성한다 

 

//cognito-userpool.ts

import { CognitoUserPool } from 'amazon-cognito-identity-js'

declare const globalThis: {
    cognitoUserPoolGlobal: CognitoUserPool
} & typeof global

const userPool =
    globalThis.cognitoUserPoolGlobal ??
    new CognitoUserPool({
        UserPoolId: process.env.COGNITO_USER_POOL_ID!,
        ClientId: process.env.COGNITO_CLIENT_ID!,
    })

export default userPool

if (process.env.NODE_ENV !== 'production') globalThis.cognitoUserPoolGlobal = userPool

 

일단 회원가입에도 쓸 userpool을 먼저 작성해준다

매번 initialize하면 자원낭비이니 globalThis에 등록시켜 뜯어 쓰게끔한다.

 

어디서 많이 본 코드일 수도 있는데 많이 본 코드 맞을 것이다

prisma의 컨넥션 풀을 위한 최적화 방법에서 응용한 방법. 다음 링크를 참조하자

https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices

 

Best practice for instantiating Prisma Client with Next.js | Prisma Documentation

Best practice for instantiating Prisma Client with Next.js

www.prisma.io

 

// CognitoAuthentication.ts

import { CognitoUserType } from '@entities/auth'
import { AuthenticationDetails, CognitoUser } from 'amazon-cognito-identity-js'
import CredentialsProvider from 'next-auth/providers/credentials'
import userPool from './cognito-userpool'

export const CognitoAuthentication = CredentialsProvider({
    name: 'cognito',
    credentials: {
        email: { label: 'Email', type: 'text' },
        password: { label: 'Password', type: 'password' },
    },

    async authorize(credentials) {
        const cognitoUser = new CognitoUser({
            Username: credentials?.email as string,
            Pool: userPool,
        })

        const authenticationDetails = new AuthenticationDetails({
            Username: credentials?.email as string,
            Password: credentials?.password as string,
        })

        return new Promise<CognitoUserType>((resolve, reject) => {
            cognitoUser.authenticateUser(authenticationDetails, {
                onSuccess: (session) => {
                    const info = session.getIdToken().payload
                    resolve({
                        id: info.sub,
                        email: info.email,
                        nickname: info.nickname,
                    })
                },
                onFailure: reject,
            })
        })
    },
})

 

아까 작성한 userpool과, cognitoUser 클래스 초기화

다음 초기화된 userclass에서 프론트에서 받아올 email/password와 함께 로그인을 진행하는 provider이다

 

나머지는 authjs 에서 제공하는 가이드에 맞춰서 route를 작성하면된다 

 

설명없이 바로 적자면

 

nextauth를 위한 auth.ts작성

import NextAuth from 'next-auth'
import { CognitoAuthentication } from './cognito'

export const { auth, handlers, signIn, signOut } = NextAuth({
    providers: [CognitoAuthentication],
    callbacks: {
        async jwt({ token, user }) {
            return { ...token, ...user }
        },
        async session({ session, token }) {
            session.user = token
            return session
        },
    },
})

 

 

auth.js를 이용해서 

/app/api/auth/[...nextauth]/route.ts에 다음과 같이 작성 

import { handlers } from '@shared/auth'

export const { GET, POST } = handlers

 

 

다음으로는 register도 작성해주자

/app/api/register

import { UserRegisteration } from '@entities/auth'
import userPool from '@shared/auth/cognito-userpool'
import { CognitoUserAttribute } from 'amazon-cognito-identity-js'
import { randomUUID } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'

export const POST = async (req: NextRequest) => {
    const body = await req.json()
    try {
        const { email, nickname, password } = UserRegisteration.parse(body)
        await new Promise((resolve, reject) => {
            userPool.signUp(
                randomUUID(),
                password,
                [new CognitoUserAttribute({ Name: 'email', Value: email }), new CognitoUserAttribute({ Name: 'nickname', Value: nickname })],
                [],
                (err, result) => {
                    if (err) {
                        reject(err)
                    } else {
                        resolve(result)
                    }
                },
            )
        })
        return NextResponse.json(null, { status: 200 })
    } catch (error) {
        return NextResponse.json(null, { status: 500 })
    }
}

 

UserRegistoration은 보시다시피 zod객체인데 다음과 같다

export const User = z.object({
    id: z.string(),
    email: z.string().email(),
    password: z.string(),
    name: z.string(),
    nickname: z.string(),
})

export const UserRegisteration = User.pick({ email: true, password: true, nickname: true })

 

프론트는 회원가입시에 /api/register로 email과 password, nickname만 날려주면 된다.

 

 

페이지 작성

 

api는 대충 끝났고 이번에는 프론트 페이지들을 작성해보자

 

프론트 페이지는 매우 간단한데 그냥 평상시와 같이 로그인과 회원가입 페이지를 작성해주면 된다

 

참고로 shadcn/ui 사용으니 써보지 않았다면 바로 홈페이지가서 사용해보는 것을 추천한다

https://ui.shadcn.com/

 

shadcn/ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.

ui.shadcn.com

 

 

먼저 로그인 페이지

 

/app/login/page.tsx

'use client'
import { Button, buttonVariants } from '@shared/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@shared/ui/card'
import { Input } from '@shared/ui/input'
import { Label } from '@shared/ui/label'
import { useToast } from '@shared/ui/use-toast'
import { cn } from '@shared/utils'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { ChangeEventHandler, useState } from 'react'

const LoginPage = () => {
    const router = useRouter()
    const { toast } = useToast()
    const [loginData, setLoginData] = useState({
        email: '',
        password: '',
    })

    const onChange: ChangeEventHandler<HTMLInputElement> = (e) => {
        setLoginData({ ...loginData, [e.target.name]: e.target.value })
    }

    const login = () =>
        signIn('credentials', {
            email: loginData.email,
            password: loginData.password,
            redirect: false,
        }).then((res) => {
            if (res?.error) {
                toast({
                    title: 'Error',
                    description: 'Invalid email or password',
                })
                return
            }
            toast({
                title: 'Success',
                description: 'You have successfully logged in',
            })
            router.push('/')
        })

    return (
        <section className='flex flex-col items-center justify-center my-auto'>
            <Card className='w-full max-w-sm border-none shadow-none'>
                <CardHeader>
                    <CardTitle className='text-2xl'>Login</CardTitle>
                </CardHeader>
                <CardContent className='grid gap-4'>
                    <div className='grid gap-2'>
                        <Label htmlFor='email'>Email</Label>
                        <Input onChange={onChange} name='email' id='email' type='email' placeholder='m@example.com' required />
                    </div>
                    <div className='grid gap-2'>
                        <Label htmlFor='password'>Password</Label>
                        <Input onChange={onChange} name='password' id='password' type='password' required />
                    </div>
                </CardContent>

                <CardFooter className='flex flex-col items-start gap-2'>
                    <Button onClick={login} className='w-full'>
                        Sign in
                    </Button>
                    <CardDescription>
                        <Link className={cn(buttonVariants({ variant: 'link' }), 'px-0')} href={'/register'}>
                            Not registered yet? Sign up
                        </Link>
                    </CardDescription>
                </CardFooter>
            </Card>
        </section>
    )
}

export default LoginPage

 

/app/register/page.tsx

'use client'

import { UserRegisteration } from '@entities/auth'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '@shared/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@shared/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared/ui/form'
import { Input } from '@shared/ui/input'
import { useToast } from '@shared/ui/use-toast'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

const RegisterPage = () => {
    const { toast } = useToast()
    const router = useRouter()
    const form = useForm<z.infer<typeof UserRegisteration>>({
        resolver: zodResolver(UserRegisteration),
        defaultValues: {
            email: '',
            password: '',
            nickname: '',
        },
    })

    const onSubmit = async (values: z.infer<typeof UserRegisteration>) => {
        const data = await fetch('/api/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(values),
        })
        data.ok &&
            toast({
                title: 'Success',
                description: 'You have successfully registered',
            })
        data.ok && router.push('/login')
    }

    return (
        <section className='flex flex-col items-center justify-center my-auto'>
            <Card className='w-full max-w-md border-none shadow-none'>
                <CardHeader className='font-bold text-3xl'>
                    <CardTitle>Register</CardTitle>
                </CardHeader>
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)}>
                        <CardContent className='flex flex-col gap-3'>
                            <FormField
                                control={form.control}
                                name='email'
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Email</FormLabel>
                                        <FormControl>
                                            <Input type={'email'} placeholder='Email' {...field} />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />

                            <FormField
                                control={form.control}
                                name='password'
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Password</FormLabel>
                                        <FormControl>
                                            <Input type={'password'} placeholder='Password' {...field} />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />

                            <FormField
                                control={form.control}
                                name='nickname'
                                render={({ field }) => (
                                    <FormItem>
                                        <FormLabel>Nickname</FormLabel>
                                        <FormControl>
                                            <Input type={'text'} placeholder='Nickname' {...field} />
                                        </FormControl>
                                        <FormMessage />
                                    </FormItem>
                                )}
                            />
                        </CardContent>
                        <CardFooter>
                            <Button className='w-full' type='submit'>
                                Submit
                            </Button>
                        </CardFooter>
                    </form>
                </Form>
            </Card>
        </section>
    )
}

export default RegisterPage

 

 

이렇게 작성이 끝났다

 

토요일 저녁에 5시간정도 달린것같다 

뭔가에 홀리듯이 template 프로젝트를 켜서 cognito를 넣겠다는 강력한 생각으로 새벽 3시까지 달린듯한데 ..

 

여하튼 뭐 작동 잘하는 것 보니 만족한다 

 

모든 코드는 template에서 공개하고있으니 

잘 보았다면 star하나 추가 부탁드린다

https://github.com/B-HS/template

 

GitHub - B-HS/template: Template for my projects

Template for my projects. Contribute to B-HS/template development by creating an account on GitHub.

github.com

 

 

 

 


프로그래밍/개인홈페이지의 다른 글