똑같은 삽질은 2번 하지 말자

React vol.21 NextJS + App Router + edge runtime에서 next-auth와 cognito로 인증 시스템 구현하기 본문

카테고리 없음

React vol.21 NextJS + App Router + edge runtime에서 next-auth와 cognito로 인증 시스템 구현하기

곽빵 2024. 2. 24. 22:03

개요

Cloudflare Pages + Workers를 이용해 edge runtime의 환경에서 next-auth + cognito를 이용해 인증 시스템을 구축했던 기록을 남기고자 한다.

 

next-auth v4는 edge runtime환경에서 사용할 수 없었다.

도입을 하려고 이것저것 조사를 했는데 edge runtime의 환경에서 NodeJS의 API을 사용할 수 있는게 제한적이다보니 next-auth에서 사용하는 NodeJS의 https, cryto등으로 인해 next-auth v4를 edge runtime에서 사용할 수 없었다.

 

하지만 next-auth v5(authjs)에서는 사용할 수 있었다.

아직 beta이긴 하지만 v5에서는 edge runtime에서 사용할 수 있게끔 대응을 해주고 있었으므로 이를 활용해 구축해보자.

https://authjs.dev/getting-started/introduction#flexible

 

Introduction | Auth.js

About Auth.js

authjs.dev

환경구축

npx create-next-app@latest

페이지 추가

/src/app/news/page.tsx

export default function News() {
  return <div>News page</div>;
}

 

/src/app/login/page.tsx

export default function Login() {
  return <div>Login page</div>;
}

 

News페이지는 로그인을 해야 접근할 수 있는 페이지이고

Login페이지는 물론 로그인을 하지 않아도 접근할 수 있는 페이지이어야 하는 점을 명심하자.

auth.js(next-auth)의 도입과 설정

npm i next-auth@beta

 

/.env

AUTH_SECRET=some-secret
  • AUTH_SECRET: v4에서 NEXTAUTH_SECRET의 이름이 변경된 친구로 jwt-token의 암호화와 복호화등에 사용되며 필수로 요구되어지는 환경변수이므로 설정해둔다. 백엔드에게 유효한 jwt token임을 확인받으려면 당연히 이 환경변수도 백엔드와 공유되어야 한다.

AWS Cognito의 도입과 설정

cognito관련의 인증등의 처리를 손 쉽게 하기 위한 라이브러리 도입

npm i amazon-cognito-identity-js

/.env

# authjs
AUTH_SECRET=some-secret

# cognito
NEXT_PUBLIC_COGNITO_USER_POOL_ID=xxx
NEXT_PUBLIC_COGNITO_CLIENT_ID=xxx
  • NEXT_PUBLIC_COGNITO_USER_POOL_ID: Cognito에서 UserPool을 생성할 때 자동으로 부여받는 ID
  • NEXT_PUBLIC_COGNITO_CLIENT_ID: Cognito에서 UserPool을 생성할 때 자동으로 부여받는 ID

auth.js와 Cognito의 연계를 위한 타입설정

/src/types/next-auth.d.ts

import 'next-auth';

import { CognitoAccessToken, CognitoIdToken, CognitoRefreshToken } from 'amazon-cognito-identity-js';

declare module 'next-auth' {
  interface Session {
    idToken?: CognitoIdToken | string;
    accessToken?: CognitoAccessToken | string;
    error?: string;
  }

  interface User {
    id?: string;
    idToken?: CognitoIdToken | string;
    accessToken?: CognitoAccessToken | string;
    refreshToken?: CognitoRefreshToken | string;
    expiresIn?: number;
  }

  interface JWT {
    idToken?: CognitoIdToken | string;
    accessToken?: CognitoAccessToken | string;
    refreshToken?: CognitoRefreshToken | string;
    expiresIn?: number;
    error?: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    idToken?: CognitoIdToken | string;
    accessToken?: CognitoAccessToken | string;
    refreshToken?: CognitoRefreshToken | string;
    expiresIn?: number;
    error?: string;
  }
}

 

authjs의 기존 Token들의 타입에 Cognito와의 연계를 위한 타입을 확장시켜놓는다. 여기서 cognito가 아닌 다른 Sass나 OAuth를 사용하는 경우에는 그에 맞는 타입을 확장시켜주면 된다.

authConfig의 작성과 Cognito Provider의 등록

/auth.config.ts

import {
  AuthenticationDetails,
  CognitoIdToken,
  CognitoUser,
  CognitoUserPool,
  CognitoUserSession,
} from 'amazon-cognito-identity-js';
import type { NextAuthConfig } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export const authConfig: NextAuthConfig = {
  providers: [
    CredentialsProvider({
      credentials: {
        email: { label: 'Email', type: 'text' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async credentials => {
        const email = (credentials?.email as string) ?? '';
        const password = (credentials?.password as string) ?? '';

        const userPool = new CognitoUserPool({
          UserPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID!,
          ClientId: process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID!,
        });

        const user = new CognitoUser({
          Username: email,
          Pool: userPool,
        });

        const authenticationDetails = new AuthenticationDetails({
          Username: email,
          Password: password,
        });

        const session: CognitoUserSession = await new Promise((resolve, reject) => {
          user.authenticateUser(authenticationDetails, {
            onSuccess: session => resolve(session),
            onFailure: err => reject(err),
            newPasswordRequired: function (userAttributes, requiredAttributes) {
              user.completeNewPasswordChallenge(password, {}, this);
            },
          });
        });

        if (!session) {
          return null;
        }

        return {
          email,
          idToken: session.getIdToken(),
          accessToken: session.getAccessToken(),
          refreshToken: session.getRefreshToken(),
        };
      },
    }),
  ],
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // 24 hours
  },
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const callbackUrl = nextUrl.searchParams.get('callbackUrl');
      if (isLoggedIn) {
        if (callbackUrl) {
          return Response.redirect(callbackUrl);
        } else {
          return true;
        }
      } else {
        return false;
      }
    },
    async session({ session, token }) {
      session.user.id = (token.idToken as CognitoIdToken)?.payload?.sub ?? '';
      session.user.email = token.email ?? '';
      return session;
    },
    async jwt({ token, user, trigger }) {
      if (user && trigger === 'signIn') {
        token.idToken = user.idToken;
        token.accessToken = user.accessToken;
        token.refreshToken = user.refreshToken;
        token.email = user.email;
      }
      return token;
    },
  },
};

 

  • providers: NextAuth에서 사용할 인증 방식을 정의한다. 여기서는 CredentialsProvider를 사용해 Cognito의 이메일과 비밀번호 기반의 인증을 구현.
    • authorize: 사용자가 제공한 이메일과 비밀번호를 사용하여 AWS Cognito를 통한 인증을 수행하는 비동기 함수이다. 성공 시 사용자 세션 정보를 반환하며, 실패하면 null을 반환하여 인증 실패를 나타냄.
      • CognitoUserPool과 CognitoUser 객체를 생성하여 Cognito 서비스와의 연동을 설정
      • authenticateUser 메서드를 사용하여 사용자 인증을 시도하고, 결과에 따라 세션 정보를 반환하거나 오류를 처리
      • newPasswordRequired는 초기 패스워드의 강제변경요구의 상태인 유저가 로그인을 하면 인증됨 상태로 자동 변경을 해주는 역할이다.
  • session: JWT(Json Web Tokens) 기반 세션 관리 전략을 사용, 세션 유효 시간과 업데이트 주기를 설정
  • pages: 로그인 페이지의 경로를 설정한다. 사용자가 인증되지 않았을 리디렉션될 페이지이다
  • callbacks
    • authorized: 요청이 인증되었는지 여부를 결정하는 함수이다. 사용자가 로그인된 상태인지 확인하고, 필요한 경우 콜백 URL로 리디렉션한다
    • session: 세션 객체를 사용자 정보와 토큰으로 업데이트하는 함수이다. 사용자의 ID와 이메일 정보를 세션에 추가한다. (추후 useSession을 통해 현재 로그인한 유저정보습득을 위해 필요하다.)
    • jwt: JWT 토큰을 사용자 정보로 업데이트하는 함수이다. 사용자가 로그인할 , 토큰에 사용자의 ID, 액세스 토큰, 리프레시 토큰, 이메일 정보를 저장한다.

정의한 authConfig를 authjs에 설정한 뒤 필요한 인증 기능들을 추출

/src/auth.ts

import NextAuth from 'next-auth';

import { authConfig } from '../auth.config';

export const {
  handlers: { GET, POST },
  signIn,
  signOut,
} = NextAuth(authConfig);
  • GET, POST: 추후에 api/[...nextauth]/route.ts에 설정할 API들이다. useSession등을 사용할 때 next-auth가 내부적으로 request하는 api들이 있는데 이것들이 GET, POST 내부에 들어있는것으로 보인다.
  • signIn: 로그인 함수
  • signOut: 로그아웃 함수

추출한 signIn과 signUp을 Server에서 동작하도록 액션 작성

/src/app/_authAction/index.ts

"use server";

import { signIn, signOut } from "@/auth";

export async function login(
  formData: { email: string; password: string },
  provider?: "credentials"
) {
  try {
    await signIn(provider, formData);
  } catch (error) {
    throw error;
  }
}

export async function logout() {
  try {
    await signOut();
  } catch (error) {
    throw error;
  }
}

인증 미들웨어 작성

/src/middleware.ts

import NextAuth from "next-auth";

import { authConfig } from "../auth.config";

export default NextAuth(authConfig).auth;

export const config = {
  // /api, /login, image파일들은 middleware(auth)の대상외
  matcher: [
    "/((?!api|login|_next/static|_next/image|images|icons|icon.ico).*)",
  ],
};
  • NextAuth(authConfig).auth: authjs의 auth함수를 미들웨어 함수로써 등록한다. 이는 로그인이 안되어있는 상태라면 대상외 등록한 이외의 URL로 접근할 때 접근이 안되며 로그인 페이지로 리다이렉트 되도록 한다.
  • matcher: 미들웨어를 적용할 부분과 적용안하는 부분을 정의하는 친구

이제 news페이지는 로그인 없이는 접근이 불가능하다.

로그인 화면 작성

/src/app/login/page.tsx

"use client";

import { login } from "@/app/_authAction";
import { useState } from "react";

export default function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleLogin = async () => {
    await login({
      email,
      password,
    });
  };

  return (
    <div className="flex flex-col gap-2 w-[300px]">
      <label>
        メールアドレス:
        <input
          type="email"
          name="email"
          onChange={(e) => setEmail(e.target.value)}
        />
      </label>
      <label>
        パスワード:
        <input
          type="password"
          name="password"
          onChange={(e) => setPassword(e.target.value)}
        />
      </label>
      <button className="inline-block" onClick={handleLogin}>
        로그인
      </button>
    </div>
  );
}

 

이제 로그인을 할 수 있으며 로그인을 하지 않으면 접근할 수 없는 페이지등이 생긴다.

어느정도 인증 시스템이 구현되었다.

 

다음은 현재 로그인한 유저의 정보를 가져오는 글을 작성해보자

Comments