#8 Authentication
1. 이메일 회원가입 부분을 진행해보자
*create-account/actions.ts
"use server";
import bcrypt from "bcrypt";
import {
PASSWORD_MIN_LENGTH,
PASSWORD_REGEX,
PASSWORD_REGEX_ERROR,
} from "@/lib/constants";
import db from "@/lib/db";
import { z } from "zod";
const checkUsername = (username: string) => !username.includes("potato");
const checkPasswords = ({
password,
confirm_password,
}: {
password: string;
confirm_password: string;
}) => password === confirm_password;
const checkUniqueUsername = async (username: string) => {
const user = await db.user.findUnique({
where: {
username,
},
select: {
id: true,
},
});
// if (user) {
// return false;
// } else {
// return true;
// }
return !Boolean(user);
};
const checkUniqueEmail = async (email: string) => {
const user = await db.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
return Boolean(user) === false;
};
const formSchema = z
.object({
username: z
.string({
invalid_type_error: "Username must be a string!",
required_error: "Where is my username???",
})
.toLowerCase()
.trim()
// .transform((username) => `🔥 ${username} 🔥`)
.refine(checkUsername, "No potatoes allowed!")
.refine(checkUniqueUsername, "This username is already taken"),
email: z
.string()
.email()
.toLowerCase()
.refine(
checkUniqueEmail,
"There is an account already registered with that email."
),
password: z.string().min(PASSWORD_MIN_LENGTH),
//.regex(PASSWORD_REGEX, PASSWORD_REGEX_ERROR),
confirm_password: z.string().min(PASSWORD_MIN_LENGTH),
})
.refine(checkPasswords, {
message: "Both passwords should be the same!",
path: ["confirm_password"],
});
export async function createAccount(prevState: any, formData: FormData) {
const data = {
username: formData.get("username"),
email: formData.get("email"),
password: formData.get("password"),
confirm_password: formData.get("confirm_password"),
};
const result = await formSchema.safeParseAsync(data);
if (!result.success) {
return result.error.flatten();
} else {
const hashedPassword = await bcrypt.hash(result.data.password, 12);
const user = await db.user.create({
data: {
username: result.data.username,
email: result.data.email,
password: hashedPassword,
},
select: {
id: true,
},
});
// log the user in
// redirect "/home"
}
}
1) 유저네임과 이메일 중복 여부 체크를 db에서 해야하는데, 이를 zod와 엮어서 진행함. 이때,
const result = await formSchema.safeParseAsync(data); 이와 같이 이부분에 await를 추가해주어야하고, safeParseAsync로 변경함.
2) prisma에서 id만 가져오려면 select 부분에서 id:true 해주면됨
3) npm i bcrypt / npm i @types/bcrypt 설치 후 비밀번호를 해쉬처리 및 db에 저장해줌
2. Iron Session (쿠키 / 세션 부분)
npm i iron-session 설치
계정 생성이나, 로그인을 하게 될 때 쿠키를 생성해주어야 한다. (로그인 후 id정보를 넣어줘야함)
쿠키데이터에 id 정보 뿐만 아니라, 서비스 특성에 따라 역할 (관리자, 고객A, 고객B와 같이 특성 role을 할당해주어도 될듯 하다)
*lib/session.ts
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
interface SessionContent {
id?: number;
}
export default function getSession() {
return getIronSession<SessionContent>(cookies(), {
cookieName: "delicious-karrot",
password: process.env.COOKIE_PASSWORD!,
});
}
1) 별도로 session 파일을 만들어 주었다. (계정생성 및 로그인할 때 동시에 쓰이기 때문)
2) 타입지정한 이유는, 다른곳에서 id 지정 시, 에러가 뜨기 때문에
3) 쿠키네임은 내가 임의로 만들어도 되고, COOKIE_PASSWORD는 env파일에 별도로 저장해 주자.
(https://1password.com/password-generator/) - *길이 32 이상 필요
*create-account/actions.ts
"use server";
import bcrypt from "bcrypt";
import {
PASSWORD_MIN_LENGTH,
PASSWORD_REGEX,
PASSWORD_REGEX_ERROR,
} from "@/lib/constants";
import db from "@/lib/db";
import { z } from "zod";
import { getIronSession } from "iron-session";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import getSession from "@/lib/session";
const checkUsername = (username: string) => !username.includes("potato");
const checkPasswords = ({
password,
confirm_password,
}: {
password: string;
confirm_password: string;
}) => password === confirm_password;
const checkUniqueUsername = async (username: string) => {
const user = await db.user.findUnique({
where: {
username,
},
select: {
id: true,
},
});
// if (user) {
// return false;
// } else {
// return true;
// }
return !Boolean(user);
};
const checkUniqueEmail = async (email: string) => {
const user = await db.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
return Boolean(user) === false;
};
const formSchema = z
.object({
username: z
.string({
invalid_type_error: "Username must be a string!",
required_error: "Where is my username???",
})
.toLowerCase()
.trim()
// .transform((username) => `🔥 ${username} 🔥`)
.refine(checkUsername, "No potatoes allowed!")
.refine(checkUniqueUsername, "This username is already taken"),
email: z
.string()
.email()
.toLowerCase()
.refine(
checkUniqueEmail,
"There is an account already registered with that email."
),
password: z.string().min(PASSWORD_MIN_LENGTH),
//.regex(PASSWORD_REGEX, PASSWORD_REGEX_ERROR),
confirm_password: z.string().min(PASSWORD_MIN_LENGTH),
})
.refine(checkPasswords, {
message: "Both passwords should be the same!",
path: ["confirm_password"],
});
export async function createAccount(prevState: any, formData: FormData) {
const data = {
username: formData.get("username"),
email: formData.get("email"),
password: formData.get("password"),
confirm_password: formData.get("confirm_password"),
};
const result = await formSchema.spa(data);
if (!result.success) {
return result.error.flatten();
} else {
const hashedPassword = await bcrypt.hash(result.data.password, 12);
const user = await db.user.create({
data: {
username: result.data.username,
email: result.data.email,
password: hashedPassword,
},
select: {
id: true,
},
});
const session = await getSession();
session.id = user.id;
await session.save();
redirect("/profile");
}
}
1) 계성생성이 성공한 후, 세션 id에 user.id를 할당 및 세션을 저장 해준다. (위에서 만든 getSession을 통해)
*login/actions.ts
"use server";
import bcrypt from "bcrypt";
import {
PASSWORD_MIN_LENGTH,
PASSWORD_REGEX,
PASSWORD_REGEX_ERROR,
} from "@/lib/constants";
import db from "@/lib/db";
import { z } from "zod";
import getSession from "@/lib/session";
import { redirect } from "next/navigation";
const checkEmailExists = async (email: string) => {
const user = await db.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
// if(user){
// return true
// } else {
// return false
// }
return Boolean(user);
};
const formSchema = z.object({
email: z
.string()
.email()
.toLowerCase()
.refine(checkEmailExists, "An account with this email does not exist."),
password: z.string({
required_error: "Password is required",
}),
// .min(PASSWORD_MIN_LENGTH),
// .regex(PASSWORD_REGEX, PASSWORD_REGEX_ERROR),
});
export async function logIn(prevState: any, formData: FormData) {
const data = {
email: formData.get("email"),
password: formData.get("password"),
};
const result = await formSchema.spa(data);
if (!result.success) {
return result.error.flatten();
} else {
const user = await db.user.findUnique({
where: {
email: result.data.email,
},
select: {
id: true,
password: true,
},
});
const ok = await bcrypt.compare(
result.data.password,
user!.password ?? "xxxx"
);
if (ok) {
const session = await getSession();
session.id = user!.id;
await session.save();
redirect("/profile");
} else {
return {
fieldErrors: {
password: ["Wrong password."],
email: [],
},
};
}
}
}
1) 계정 생성 로직과 유사.
2) bcrypt를 통해 사용자가 입력한 비번과, db에 해쉬화된 비번값을 비교
3) safeParseAsync대신에 spa로 사용해도됨
4) 로그인 데이터가 ok면, 계정생성과 똑같이 세션id에 user.id를 할당해줌 (로그인 처리하기위해)
5) 이메일 존재하는지 db 1차 검사 -> 비번 확인 -> 맞으면 세션 처리 -> 안맞으면 fieldErrors에 password 부분에 에러메시지 전달해줌 (zod처럼)
**브라우저에서 쿠키 데이터를 확인 할 수 있음
3. superRefine
refine을 통해 여러 form에 내가 원하는 로직을 넣어 유효성 검증을 할 수 있다.
만약 유저네임, 이메일, 비번 중복 확인 3개의 유효성 검증을 해야 할 때,
3개다 문제가 있으면 에러 화면이 동시에 나오게 된다. 동시에 나오게 되면 얻는 이점이 있겠지만, 내가 원하는 순서대로 1개씩 나오게 컨트롤 하기 위해서는 supreRefine을 사용하면 된다.
기존에 refine. refine. refine 중복으로 하던거를 중간에 supreRefine을 넣고 여러 값들을 일부 설정해주면 된다.
*create-account/actions.ts
"use server";
import bcrypt from "bcrypt";
import {
PASSWORD_MIN_LENGTH,
PASSWORD_REGEX,
PASSWORD_REGEX_ERROR,
} from "@/lib/constants";
import db from "@/lib/db";
import { z } from "zod";
import { redirect } from "next/navigation";
import getSession from "@/lib/session";
const checkUsername = (username: string) => !username.includes("potato");
const checkPasswords = ({
password,
confirm_password,
}: {
password: string;
confirm_password: string;
}) => password === confirm_password;
const formSchema = z
.object({
username: z
.string({
invalid_type_error: "Username must be a string!",
required_error: "Where is my username???",
})
.toLowerCase()
.trim()
// .transform((username) => `🔥 ${username} 🔥`)
.refine(checkUsername, "No potatoes allowed!"),
email: z.string().email().toLowerCase(),
password: z.string().min(PASSWORD_MIN_LENGTH),
//.regex(PASSWORD_REGEX, PASSWORD_REGEX_ERROR),
confirm_password: z.string().min(PASSWORD_MIN_LENGTH),
})
.superRefine(async ({ username }, ctx) => {
const user = await db.user.findUnique({
where: {
username,
},
select: {
id: true,
},
});
if (user) {
ctx.addIssue({
code: "custom",
message: "This username is already taken",
path: ["username"],
fatal: true,
});
return z.NEVER;
}
})
.superRefine(async ({ email }, ctx) => {
const user = await db.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
if (user) {
ctx.addIssue({
code: "custom",
message: "This email is already taken",
path: ["email"],
fatal: true,
});
return z.NEVER;
}
})
.refine(checkPasswords, {
message: "Both passwords should be the same!",
path: ["confirm_password"],
});
export async function createAccount(prevState: any, formData: FormData) {
const data = {
username: formData.get("username"),
email: formData.get("email"),
password: formData.get("password"),
confirm_password: formData.get("confirm_password"),
};
const result = await formSchema.spa(data);
if (!result.success) {
console.log(result.error.flatten());
return result.error.flatten();
} else {
const hashedPassword = await bcrypt.hash(result.data.password, 12);
const user = await db.user.create({
data: {
username: result.data.username,
email: result.data.email,
password: hashedPassword,
},
select: {
id: true,
},
});
const session = await getSession();
session.id = user.id;
await session.save();
redirect("/profile");
}
}
1) supreRefine부분을 확인해보자. path 지정, fatal:true, return z.NEVER 등을 추가해주어야 한다.
4. logout 부분
*profile/page.tsx
import db from "@/lib/db";
import getSession from "@/lib/session";
import { notFound, redirect } from "next/navigation";
async function getUser() {
const session = await getSession();
if (session.id) {
const user = await db.user.findUnique({
where: {
id: session.id,
},
});
if (user) {
return user;
}
}
notFound();
}
export default async function Profile() {
const user = await getUser();
const logOut = async () => {
"use server";
const session = await getSession();
session.destroy();
redirect("/");
};
return (
<div>
<h1>Welcome! {user?.username}!</h1>
<form action={logOut}>
<button>Log out</button>
</form>
</div>
);
}
1) 로그아웃 버튼을 button onClick을 사용하려면 클라이언트 컴포넌트에서 처리해야 하는데, 이러면 세션 관리와 같은 서버측 기능을 수행 할 수 없다. 따라서, 서버측 기능을 수행하기 위해 nextjs 트릭으로 form - server action을 대체해서 사용했다.
2) 로그아웃 버튼을 누르면 저장된 세션을 삭제해주고 redirect 처리해주었다.
3) 처음 profile url을 접속하게 되면 먼저 세션값에 있는 id를 확인해보고 세션값이 없거나, 세션id가 db에서 조회가 안될 때는, notFound 화면으로 처리 해준다. (나중에 notFound 화면은 다시 커스터마이징 예정)
**브라우저 쿠키데이터는 브라우저를 닫아도 계속 남아 있다.
5. middleware
nextjs의 미들웨어는 Edge Runtime에서 가동된다. nodejs의 경량화 버전으로 자바스크립트로 할 수 있는 모든것들을 할 수 없다. (미들웨어에서 사용할 수 있는 간략한 기능만 사용 가능 / ex: 쿠키 받아오기, 리다이렉트 등)
https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes
Rendering: Edge and Node.js Runtimes | Next.js
Learn about the switchable runtimes (Edge and Node.js) in Next.js.
nextjs.org
*middleware.ts
import { NextRequest, NextResponse } from "next/server";
import getSession from "./lib/session";
interface Routes {
[key: string]: boolean;
}
const publicOnlyUrls: Routes = {
"/": true,
"/login": true,
"/sms": true,
"/create-account": true,
};
export async function middleware(request: NextRequest) {
const session = await getSession();
const exists = publicOnlyUrls[request.nextUrl.pathname];
if (!session.id) {
if (!exists) {
return NextResponse.redirect(new URL("/", request.url));
}
} else {
if (exists) {
return NextResponse.redirect(new URL("/products", request.url));
}
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
1) 아무것도 설정하지 않을 때 미들웨어를 실행하면, 미들웨어가 여러번 실행 하게 된다. 그 이유는, static이나 image같은 여러 request에 대해 각 각의 미들웨어가 실행 되기 때문이다. 내가 원하는 url path에 대해서만 미들웨어를 실행하기 위해서는 config의 matcher를 활용하면 된다. 이는 미들웨어를 필터링하여 특정 경로에서만 실행되도록 할 수 있게 도와준다.
```
export const config = {
matcher: ['profile', '/about/:path*', '/dashboard/:path*'],
}
혹은 matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"]
```
https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
Routing: Middleware | Next.js
Learn how to use Middleware to run code before a request is completed.
nextjs.org
2) 위 코드의 미들웨어 실행 로직은 session id 검증 및 publicOnlyUrls를 검증 한 후, 서비스 로직에 맞게 리다이렉팅 해주는 로직이다. 추 후 강의에서 더 다룰 예정이다.