npm install next-auth @prisma/client @next-auth/prisma-adapter
npm install prisma --save-dev
PrismaClient 인스턴스 생성을 하여 데이터에 접근한다. prividers로 어떻게 로그인할건지 정한다. (CredentialsProvider... )
src/pages/api/auth/[...nextauth].js
: /api/auth로의 API요청을 Next-Auth가 처리
src/pages/api/auth/[...nextauth].js
https://authjs.dev/reference/adapter/prisma
공식문서의 코드 가져온다.
import NextAuth from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export default NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
})
authOptions은 따로 다시 재사용할거라 다음과 같이 수정한다.
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
// import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!, // undefined 아님 명시
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
};
export default NextAuth(authOptions);
보통은 app폴더안에 api폴더안에서 처리를 하는데,
현재는 app안에서 작동안돼서 pages폴더를 만들어 사용함.
추후 업데이트 유의
https://next-auth.js.org/providers/credentials
src/pages/api/auth/[...nextauth].js
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
// import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import CredentialsProvider from "next-auth/providers/credentials";
const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" }
},
async authorize(credentials, req) {
const user = { id: "1", name: "J Smith", email: "jsmith@example.com" }
if (user) {
return user
} else {
return null
}
}
})
],
};
export default NextAuth(authOptions);
클라이언트 컴포넌트에서 세션정보를 가져오기위해서 SesstionProvider를 감싸고 useSession Hook을 이용한다.
layout.tsx를 테스트를 위해서 임시로 client로 만들어놓고 추후에 수정할것.
'use client'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Link from 'next/link'
import Navbar from '@/components/Navbar';
import { SessionProvider } from 'next-auth/react'
const inter = Inter({ subsets: ['latin'] })
// export const metadata: Metadata = {
// title: 'Create Next App',
// description: 'Generated by create next app',
// }
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<SessionProvider>
<Navbar />
<main>{children}</main>
</SessionProvider>
</body>
</html>
);
}
NavItem.tsx
useSession()을 호출해서 콘솔에 찍어보면 data와 status가 나옴.
로그인시 data에 정보를 받고 status가 변경되므로
destructuring해서 풀어준다.
const { data: session, status } = useSession();
console.log({ session }, status);
로그인/로그아웃버튼을 처리해준다.
{session?.user ? (
<li className="py-2 text-center border-b-4 cursor-pointer">
<button onClick={() => signOut()}>Sign out</button>
</li>
) : (
<li className="py-2 text-center border-b-4 cursor-pointer">
<button onClick={() => signIn()}>Sign in</button>
</li>
)}
next auth에서 제공되는 signOut과 signIn을 호출하면 자동으로 처리를 해주는 페이지로 이동이된다.
[...nextauth].ts
에서 providers안에서 label이나 placeholder등을 설정할 수 있다.
리턴하는 user는 NavItem에서 session의 user와 같다.
[...nextauth].ts
providers하단에 다음 세션 설정을 추가한다.
session: {
// strategy: 'database'
strategy: 'jwt'
}
database는 DB에 저장하는것이고, jwt는 토큰에 저장하는것.
세션길이 등을 설정할 수 있다.
그대로 signin을 누르면
signout을 누르면 자동으로 세션쿠키를 삭제해준다.
nextjs미들웨어를 사용하기 위해서는 next auth secret을 env에 명시해줘야한다.
.env
...
NEXTAUTH_SECRET=nextAuthScret
NEXTAUTH_URL=http://localhost:3000
src/middleware.ts
생성
export {default} from 'next-auth/middleware'
export const config = { matcher: ["/admin", "/user"] }
비로그인상태에서 user나 admin 들어가면 로그인페이지로 이동한다.
server error페이지가 뜬다면
서버 재시동하거나,
[...nextauth].ts
파일에서 secret: process.env.AUTH_SECRET,
를 추가한다.
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
secret: process.env.AUTH_SECRET,
providers: [
잘 들어가진다.
이러면 안된다!!!!
로그인페이지로 리다이렉트가 되어야한다.
이건 middleware.ts
에서 url 설정을 다음과 같이 하면된다.
export {default} from 'next-auth/middleware'
export const config = { matcher: ["/admin/:path*", "/user/:path"] };
관리자나 유저의 어떤 하위페이지도 접근이 안되고
로그인페이지로 redirect되는것을 처리하였다.
로그인시 session.user에 exp, iat, jti(jwt id) 등 토큰을 넣어주는 작업
[...nextauth].ts
callbacks: {
async jwt({ token, user }) {
console.log("token", token)
console.log("user", user)
return { ...token, ...user }
},
async session({ session, token }) {
session.user = token;
return session;
}
}
잘 들어감.
NavItem컴포넌트로 돌아와서,
session.user.
을 작성할때 id도 auto complete됐으면 한다.
이건 typescript로 처리해야한다.
src/types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user?: {
id?: string;
role?: string;
} & DefaultSession["user"];
}
}
user정보에 id와 role를 추가해준것.
로그인시 role이 관리자인경우에만 admin 메뉴를 노출할것이다.
db스키마에서 유저타입을 추가한다.
schema.prisma
enum UserType {
User
Admin
}
model User {
id String @id @default(cuid())
name String?
hashedPassword String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
userType UserType @default(User)
}
DB 싱크맞추기
npx prisma db push
(할때마다 포트 연결이 안돼서.. 할때마다 postgreSQL을 서비스 재시작을 해야된다..)
[...nextauth].ts
에서 로그인 정보에 role을 임시로 추가해준다.
세션에 role이 들어왔다.
로그인된 유저만 접근가능,
어드민 유저만 접근가능,
로그인된 유저는 로그인, 회원가입 페이지에 접근 X(/로 이동)
처리하기
[...nextauth].ts
에서 session아래 jwt설정 코드를 추가 작성한다.
session: {
// strategy: 'database'
strategy: "jwt",
},
jwt: {
secret: 'secret111',
maxAge: 30 * 24 * 60 * 60 // 30days
},
미들웨어에서 사용하는 jwt의 secret과 [...nextauth].ts
에서 secret은 같아야한다.
.env
파일에서 JWT_SECRET="jwt-secret"
로 변수를 추가하고 사용한다.
사용할때는 process.env.JWT_SECRET
라고 작성.
export async function middleware(req: NextRequest) {
const session = await getToken({ req, secret: process.env.JWT_SECRET });
console.log('session', session)
console.log("req.nextUrl.pathname", req.nextUrl.pathname);
}
콘솔결과:
이 pathname과 session을 통해서 인가처리를 해준다.
middleware.ts
import { getToken } from 'next-auth/jwt';
import { NextRequest, NextResponse } from 'next/server';
export {default} from 'next-auth/middleware'
export async function middleware(req: NextRequest) {
const session = await getToken({ req, secret: process.env.JWT_SECRET });
// console.log('session', session)
// console.log("req.nextUrl.pathname", req.nextUrl.pathname);
const pathname = req.nextUrl.pathname;
// 로그인된 유저만 접근가능
if(pathname.startsWith('/user') && !session) {
return NextResponse.redirect(new URL("/auth/login", req.url));
}
// 어드민 유저만 접근가능
if(pathname.startsWith('/admin') && (session?.role !== 'admin')) {
return NextResponse.redirect(new URL("/", req.url));
}
// 로그인된 유저는 회원가입, 로그인페이지 접근 불가
if(pathname.startsWith('/auth') && session) {
return NextResponse.redirect(new URL("/", req.url));
}
return NextResponse.next(); //통과
}
// admin과 user 하위페이지 전체 redirect처리: 테스트를 위해 주석처리
// export const config = { matcher: ["/admin/:path*", "/user/:path"] };
[...nextauth].ts
에 signIn페이지 따로 지정하는 설정 추가
session: {
// strategy: 'database'
strategy: "jwt",
},
jwt: {
secret: process.env.JWT_SECRET,
maxAge: 30 * 24 * 60 * 60 // 30days
},
pages: {
signIn: '/auth/login',
},
인풋은 자주사용하므로 재사용가능한 컴포넌트로 만든다.
유효성 체크와 최적화등을 위해 react-hook-form 라이브러리를 사용한다.
https://react-hook-form.com/
npm i react-hook-form
src/app/components/Input.tsx
import { format } from 'path';
import React from 'react'
import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form';
interface InputProps {
id: string;
label: string;
type?: string;
disabled?: boolean;
formatPrice?: boolean;
required?: boolean;
register: UseFormRegister<FieldValues>;
errors: FieldErrors;
}
const Input: React.FC<InputProps> = ({
id,
label,
type = "text",
disabled,
formatPrice,
register,
required,
errors,
}) => {
return (
<div className="relative w-full">
{formatPrice && (
<span className="absolute text-neutral-700 top-5 left-2">₩</span>
)}
<input
id={id}
disabled={disabled}
{...register(id, { required })}
placeholder=''
type={type}
className={`
w-full
p-4
pt-6
font-light
border-2
bg-white
rounded-md
outline-none
transition
disabled:opacity-70
disabled:cursor-not-allowed
${formatPrice? 'pl-9': 'pl-4'}
${errors[id] ? 'border-rose-500' : 'border-neutral-300'}
${errors[id] ? 'focus:border-rose-500' : 'focus:border-black'}
`}
/>
<label className={`
absolute
text-md
duration-150
transform
-translate-y-3
top-5
z-10
origin-[0]
${formatPrice ? 'left-9': 'left-4'}
peer-placeholder-shown:scale-100
peer-placeholder-shown:translate-y-0
peer-focus:scale-75
peer-focus:-translate-y-4
${errors[id] ? 'text-rose-500' : 'text-zinc-400'}
`}>
{label}
</label>
</div>
);
}
export default Input
아이콘 설치 npm i react-icons
src/app/components/Button.tsx
import React from 'react'
import { IconType } from 'react-icons';
interface ButtonProps {
label: string;
onClick?: (e:React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
outline?: boolean;
small?: boolean;
icon?: IconType
}
const Button: React.FC<ButtonProps> = ({
label,
onClick,
disabled,
outline,
small,
icon: Icon
}) => {
return (
<button
type="submit"
disabled={disabled}
onClick={onClick}
className={`
relative
disabled:opacity-70
disabled:cursor-not-allowed
rounded-lg
hover:opacity-80
transition
w-full
${outline ? 'bg-white': 'bg-orange-500'}
${outline ? 'bolder-black': 'border-orange-500'}
${small ? 'text-sm' : 'text-md'}
${small ? 'py-1' : 'py-3'}
${small ? 'font-light' : 'font-semibold'}
${small ? 'bolder-[1px]' : 'border-2'}
`}
>
{Icon && (
<Icon
size={24}
className='absolute left-4 top-3'
/>
)}
{label}
</button>
)
}
export default Button
src/app/auth/register/page.tsx
"use client";
import Button from "@/components/Button";
import Input from "@/components/Input";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { FieldValues, SubmitHandler, useForm } from "react-hook-form";
const RegisterPage = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FieldValues>({
defaultValues: {
name: "",
email: "",
password: "",
},
});
const onSubmit: SubmitHandler<FieldValues> = async (body) => {
setIsLoading(true);
try {
const {data} = await axios.post('/api/register', body)
console.log(data)
router.push("/auth/login")
} catch(error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
return (
<section className="grid h-[calc(100vh_-_56px)] place-items-center">
<form
className="flex flex-col justify-center gap-4 min-w-[350px]"
onSubmit={handleSubmit(onSubmit)}
>
<h1 className="text-2xl">가입하기</h1>
<Input
id="email"
label="Email"
disabled={isLoading}
register={register}
errors={errors}
/>
<Input
id="name"
label="name"
disabled={isLoading}
register={register}
errors={errors}
/>
<Input
id="password"
label="password"
type="password"
disabled={isLoading}
register={register}
errors={errors}
/>
<Button label="가입하기" />
<div className="text-center">
<p className="text-gray-400">
이미 회원이신가요?
<Link href="/auth/login" className="text-black hover:underline">
로그인하기
</Link>
</p>
</div>
</form>
</section>
);
};
export default RegisterPage;
src/app/auth/login/page.tsx
"use client";
import Button from "@/components/Button";
import Input from "@/components/Input";
import axios from "axios";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { FieldValues, SubmitHandler, useForm } from "react-hook-form";
const LoginPage = () => {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FieldValues>({
defaultValues: {
email: "",
password: "",
},
});
const onSubmit: SubmitHandler<FieldValues> = async (body) => {
setIsLoading(true);
try {
const data = signIn('credentials', body);
console.log(data);
// router.push("/");
} catch (error) {
console.log(error);
} finally {
setIsLoading(false);
}
};
return (
<section className="grid h-[calc(100vh_-_56px)] place-items-center">
<form
className="flex flex-col justify-center gap-4 min-w-[350px]"
onSubmit={handleSubmit(onSubmit)}
>
<h1 className="text-2xl">로그인</h1>
<Input
id="email"
label="Email"
disabled={isLoading}
register={register}
errors={errors}
/>
<Input
id="password"
label="password"
type="password"
disabled={isLoading}
register={register}
errors={errors}
/>
<Button label="로그인" />
<div className="text-center">
<p className="text-gray-400">
회원이 아니신가요?
<Link href="/auth/register" className="text-black hover:underline">
가입하기
</Link>
</p>
</div>
</form>
</section>
);
};
export default LoginPage;
UI작업은 끝났는데
signIn시 fake 데이터를 실제데이터로 바꾸고 기능을 구현할것이다.
테스트
src/app/api/hello/route.ts
export async function GET(request: Request) {
return new Response('Hello nextjs')
}
app/api 폴더안에 route.ts파일에 route handlers를 생성하면된다.
HTTP Methods가 지원된다.
GET
, POST
, PATCH
, DELETE
, HEAD
, OPTIONS
할일 :
POST메소드 생성,
파라미터 받고 패스워드 해싱,
db에 user 넣기(prisma client객체 만들어서)
prisma client 인스턴스는 어디서든지 재사용하기위해 따로 생성한다.
src/helpers/prismadb.ts
폴더는 helpers나 lib로 네이밍한다.
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = globalThis.prisma || new PrismaClient();
if(process.env.NODE_ENV !== 'production') globalThis.prisma = prisma;
export default prisma;
[...nextauth].ts
에서도 PrismaClient를 새로 생성하지 않고 import하여 사용한다.
import prisma from '@/helpers/prismadb'
// const prisma = new PrismaClient();
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
db에 저장할때 이 PrismaClient를 이용한다.
hashedPassword를 처리하기위해서 bcrypt를 설치한다.
npm i bcryptjs
npm i --save-dev @types/bcryptjs
src/app/api/register/route.ts
import bcrypt from "bcryptjs";
import prisma from '@/helpers/prismadb'
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const body = await request.json();
const {
email,
name,
password
} = body;
const hashedPassword = await bcrypt.hash(password, 12); // 12: salt
const user = await prisma.user.create({
data: {
email,
name,
hashedPassword
}
})
return NextResponse.json(user);
}
셀렉트 쿼리 입력해서 돌려주면 데이터 아웃풋 잘 나옴.
유저타입도 기본으로 User로 들어간다.
쿼리/데이터 아웃풋 패널 안보이면 Tools에서 쿼리툴 열어주면된다.
임시 로그인 데이터를 지우고 실제 유저 데이터를 받아서 로그인하는 것을 구현한다.
데이터베이스에서 이메일과 패스워드 둘중 하나라도 없다면 error throw
데이터베이스에서 이메일로 유저를 찾고
유저가 없거나 유저의 hashedPassword가 없으면 error throw
유저가 있다면 받은 플레인패스워드와 해싱된 패스워드를 bcrypt.compare()
로 비교
[...nextauth].ts
import bcrypt from 'bcryptjs'
...
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if(!credentials?.email || !credentials?.password) {
throw new Error('Invalid credentials')
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email
}
})
if(!user || !user?.hashedPassword) {
throw new Error("Invalid credentials");
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.hashedPassword
)
if(!isCorrectPassword) {
throw new Error("Invalid credentials");
}
return user;
},
}),
없는 유저 로그인시
있는 유저 로그인시
현재는 클라이언트 컴포넌트에서 useSession을 이용하는데
서버컴포넌트에서도 세션데이터 사용할 수 있게 한다.
src/app/user/page.tsx
import { authOptions } from '@/pages/api/auth/[...nextauth]'
import { getServerSession } from 'next-auth'
import React from 'react'
const UserPage = async () => {
const session = await getServerSession(authOptions);
console.log("server session", session)
return (
<div>로그인된 사용자만 볼 수있는 페이지</div>
)
}
export default UserPage
세션에 저장된 유저정보를 불러오는 함수 getCurrentUser를 생성하여 모듈화한다.
src/app/actions/getCurrentUser.ts
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import prisma from '@/helpers/prismadb'
export async function getSession() {
return await getServerSession(authOptions);
}
export default async function getCurrentUser() {
try {
const session = await getSession();
if(!session?.user?.email) {
return null;
}
const currentUser = await prisma?.user.findUnique({
where: {
email: session.user.email
}
})
if(!currentUser) {
return null;
}
return currentUser;
} catch(error) {
return null;
}
}
유저페이지에서 확인
const userData = await getCurrentUser();
console.log("유저데이터2",userData)
schema.prisma
model User {
id String @id @default(cuid())
name String?
hashedPassword String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userType UserType @default(User)
}
스키마파일에 두 행 추가하고
회원가입 해뒀던 유저데이터는 pgAdmin에서 삭제하고
재가입한다.
두 컬럼의 데이터가 정상적으로 출력된다.
useSession()과 sessionProvider 등을 삭제하고,
서버컴포넌트로 변경한 layout.tsx에서 currentUser를 받고
Navbar와 NavbarItem에 props로 내려준다.
Next.js 13.3.x버전에서는 서버컴포넌트와 클라이언트컴포넌트 사이에서 props를 주고받는것이 지원이 되지 않았지만 13.4.x이후부터는 가능해졌다.
getCurrentUser에서 date부분을 다음과 같이 string으로 변환 해야했다. 지금은 변환안해도 됨..
createdAt: currentUser.createdAt.toISOString()
layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import Navbar from '@/components/Navbar';
import getCurrentUser from './actions/getCurrentUser';
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const currentUser = await getCurrentUser();
return (
<html lang="en">
<body className={inter.className}>
<Navbar currentUser={currentUser} />
<main>{children}</main>
</body>
</html>
);
}
Navbar.tsx
'use client'
import Link from "next/link";
import { useState } from "react";
import NavItem from "./NavItem";
interface NavbarProps {
currentUser?: User | null;
}
const Navbar = ({ currentUser }: NavbarProps) => {
const [menu, setMenu] = useState(false);
const handleMenu = () => setMenu(!menu);
return (
<nav className="relataive z-10 w-full bg-orange-500 text-white">
<div className="flex items-center justify-between mx-5 sm:mx-10 lg:mx-20">
<div className="flex items-center text-2xl h-14">
<Link href="/">Logo</Link>
</div>
{/* sm보다 클때 */}
<div className="text-2xl sm:hidden">
{menu === false ? (
<button onClick={handleMenu}>+</button>
) : (
<button onClick={handleMenu}>-</button>
)}
</div>
<div className="hidden sm:block">
<NavItem currentUser={currentUser} />
</div>
</div>
<div className="block sm:hidden">
{menu === false ? null : <NavItem mobile />}
</div>
</nav>
);
}
export default Navbar;
NavItem.tsx
import { User } from '@prisma/client';
import { signIn, signOut } from 'next-auth/react';
import Link from 'next/link'
import React from 'react'
interface NavItemProps {
mobile?: boolean;
currentUser?: User | null;
}
const NavItem = ({ mobile, currentUser }: NavItemProps) => {
return (
<ul
className={`text-md justify-center flex gap-4 w-full items-center ${
mobile && "flex-col h-full"
}`}
>
{currentUser?.userType === "Admin" && (
<li className="py-2 text-center border-b-4 cursor-pointer">
<Link href="/admin">Admin</Link>
</li>
)}
{currentUser?.userType === "User" && (
<li className="py-2 text-center border-b-4 cursor-pointer">
<Link href="/user">{currentUser?.name}님</Link>
</li>
)}
{currentUser ? (
<li className="py-2 text-center border-b-4 cursor-pointer">
<button onClick={() => signOut()}>Sign out</button>
</li>
) : (
<li className="py-2 text-center border-b-4 cursor-pointer">
<button onClick={() => signIn()}>Sign in</button>
</li>
)}
</ul>
);
};
export default NavItem