Jungle-Board 로그인/회원가입 기능 구현 및 게시판 권한 반영하기
이어서 로그인/회원가입 기능을 구현하고 이를 기존에 있던 게시판에 붙이는 작업을 진행했습니다. Next.js에는 Nextauth.js라는 인증 라이브러리가 있어 JWT나 세션 방식, 그리고 Oauth(깃허브, 구글 기반 로그인) 인증을 손쉽게 구현할 수 있는데요. 저는 이 중 JWT 방식을 선택했습니다.
로그인 페이지
로그인 페이지는 유저 컴포넌트로 구현했습니다.
page.js 코드
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import styles from './page.module.css'
export default function SignInPage() {
const router = useRouter()
const [id, setId] = useState('')
const [password, setPassword] = useState('')
const handleLogin = async (e) => {
e.preventDefault();
const res = await signIn('credentials', {
redirect: false,
id,
password,
})
if (res.ok) {
router.push('/')
} else {
alert('로그인 실패. 아이디 또는 비밀번호를 확인하세요.')
}
}
return (
<div className={styles.container}>
<img src="/logo.png" alt="로고" className={styles.logo} />
<form onSubmit={handleLogin}>
<div className={styles.formBox}>
<input
type="text"
placeholder="아이디"
value={id}
onChange={(e) => setId(e.target.value)}
className={styles.input}
/>
<input
type="password"
placeholder="비밀번호"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={styles.input}
/>
<button type='submit' className={styles.loginButton}>
로그인
</button>
<p className={styles.signupText}>
아직 회원이 아니신가요?{' '}
<button onClick={(e) => {
e.preventDefault();
router.push('/signup');
}} className={styles.signupButton}>
회원가입
</button>
</p>
</div>
</form>
</div>
)
}
page.module.css 코드
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #f9f9f9;
padding: 1rem;
padding-bottom: 150px;
}
.logo {
width: 250px;
margin-bottom: 1rem;
}
.formBox {
width: 100%;
max-width: 400px;
background: #ffffff;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.title {
margin-bottom: 1.5rem;
text-align: center;
font-size: 1.5rem;
}
.input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
box-sizing: border-box;
}
.loginButton {
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
background-color: #4caf50;
color: #fff;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
}
.signupText {
margin-top: 1.5rem;
text-align: center;
font-size: 0.9rem;
}
.signupButton {
color: #0070f3;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: 0.9rem;
}
회원가입 페이지
회원가입 페이지 또한 유저 컴포넌트로 만들었습니다.
page.js 코드
'use client'
import { useState } from 'react';
import styles from './page.module.css';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function SignUpPage() {
const [name, setName] = useState('')
const [id, setId] = useState('')
const [password, setPassword] = useState('')
const [rePassword, setRePassword] = useState('')
const [idCheckResult, setIdCheckResult] = useState(false)
const router = useRouter();
const handleIdCheck = async () => {
if (id.trim() == '') {
alert('아이디를 입력해주세요!');
return;
}
try {
const res = await fetch(`/api/auth/register/${id}`, { method: 'GET' })
const resp = await res.json();
console.log(resp);
if (resp.exists) {
let choice = confirm('사용할 수 있는 아이디입니다. 사용하시겠습니까?');
if (choice) {
setIdCheckResult(true)
} else {
setId('')
}
} else {
alert('이미 사용중인 아이디입니다!');
}
} catch (err) {
console.error(err)
setIdCheckResult('오류가 발생했습니다.')
}
}
const handleSubmit = async (e) => {
e.preventDefault();
// 회원가입 요청 로직
if (password != rePassword) {
alert("비밀번호가 서로 다릅니다!");
return;
} else if (!idCheckResult) {
alert("아이디 중복확인을 해주세요!");
return;
}
const res = await fetch(`/api/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
userId: id,
password: password,
role: 'normal'
})
})
const resp = await res.json();
if (resp.success) {
alert("가입 완료! 로그인페이지로 이동합니다.")
router.push('/signin')
}
}
return (
<div className={styles.signup_container}>
<div style={{ textAlign: 'center' }}>
<img src="/logo.png" alt="로고" className={styles.logo} />
</div>
<h3 style={{ margin: '10px 0 30px 0' }}>가입하기</h3>
<form onSubmit={handleSubmit}>
<div className={styles.form_group}>
<label>이름</label><br></br>
<input type="text" value={name} onChange={e => setName(e.target.value)} required
placeholder='이름을 입력하세요' maxLength={5} />
</div>
<div className={styles.form_group}>
<label>아이디</label>
<div className={styles.id_check_group}>
<input type="text" value={id} onChange={e => setId(e.target.value)} required
placeholder='아이디를 입력하세요' />
<button type="button" onClick={handleIdCheck} className={styles.idCheckBtn}>중복확인</button>
</div>
</div>
<div className={styles.form_group}>
<label>비밀번호</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
placeholder='비밀번호를 입력하세요' />
</div>
<div className={styles.form_group}>
<label>비밀번호 확인</label>
<input type="password" value={rePassword} onChange={e => setRePassword(e.target.value)} required
placeholder='비밀번호를 다시 입력하세요' />
</div>
<div className={styles.buttonBox}>
<Link href={'/signin'} style={{ marginRight: '15px', textDecoration: 'none' }}>돌아가기</Link>
<button type="submit" className={styles.registerBtn}>가입하기</button>
</div>
</form>
</div>
)
}
page.module.css 코드
.signup_container {
margin: auto;
padding: 2rem;
position: relative;
margin: 23px auto 63px auto;
width: 500px;
border: 1px solid black;
border-radius: 20px;
}
.form_group {
margin-bottom: 1.2rem;
}
.id_check_group {
display: flex;
gap: 0.5rem;
}
.signup_container input[type="text"],
.signup_container input[type="password"] {
flex: 1;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 400px;
}
.idCheckBtn {
padding: 0.5rem 1rem;
border: none;
background: #0070f3;
color: white;
border-radius: 4px;
cursor: pointer;
}
.id_check_result {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #555;
}
.buttonBox {
text-align: right;
}
.registerBtn {
padding: 0.5rem 1rem;
border: none;
background: green;
color: white;
border-radius: 4px;
cursor: pointer;
}
.logo {
width: 250px;
margin-bottom: 1rem;
}
설정 파일
[...nextauth].js 코드
import { connectDB } from "@/util/database";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from 'bcrypt';
export const authOptions = {
providers: [
CredentialsProvider({
// 로그인페이지 폼 자동생성
name: "Jungle-Board",
credentials: {
id: { label: "ID", type: "text" },
password: { label: "비밀번호", type: "password" },
},
// 로그인 요청 시 실행
// 직접 DB에서 아이디, 비밀번호를 비교
// 아이디,비밀번호가 맞으면 return user, 틀리면 return null
async authorize(credentials) {
let db = (await connectDB).db('board');
let user = await db.collection('user').findOne({ userId: credentials.id })
if (!user) {
return null
}
const pwcheck = await bcrypt.compare(credentials.password, user.password);
if (!pwcheck) {
return null
}
return user
}
})
],
//3. 로그인 방식 jwt, jwt 만료일설정
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60 //30일
},
callbacks: {
// jwt 만들 때 실행되는 코드
// user변수는 DB의 유저정보가 담겨있고 token.user에 필요한 정보를 저장하면 jwt에 들어간다.
jwt: async ({ token, user }) => {
if (user) {
token.user = {};
token.user.name = user.name
token.user.userId = user.userId
token.user.role = user.role
}
return token;
},
// 유저 세션이 조회될 때 마다 실행되는 코드
session: async ({ session, token }) => {
session.user = token.user;
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
pages: {
signIn: '/signin',
},
};
export default NextAuth(authOptions);
API 구현하기
register.js (회원가입) 코드
import { connectDB } from '@/util/database';
import bcrypt from 'bcrypt';
export default async function register_handler(request, response) {
const db = (await connectDB).db('board')
if (request.method == 'POST') {
let hash = await bcrypt.hash(request.body.password, 10);
request.body.password = hash;
const result = await db.collection('user').insertOne(request.body);
if (!result.acknowledged || !result.insertedId) {
return response.status(500).json({ success: 'true', message: 'DB 에러!' });
}
return response.status(200).json({ success: 'true', message: '가입 완료!' });
}
}
register/[id].js (회원가입 시 ID 중복 체크) 코드
import { connectDB } from '@/util/database';
export default async function register_handler(request, response) {
const db = (await connectDB).db('board')
if (request.method == 'GET') {
const id = request.query.id;
const result = await db.collection('user').findOne({ userId: id })
if (result == null) {
return response.status(200).json({ exists: true });
}
return response.status(200).json({ exists: false });
}
}
로그인 유저 정보에 따라 수정/삭제 제한하기
글쓰기 페이지에서는 로그인 유저 정보에 따라 ~~님의 글을 정상적으로 표시하고, 해당 유저를 writer로 DB에 글을 작성할 수 있게 바꿨습니다. 그리고 상세페이지에서는 본인이 작성한 글에 대해서만 수정, 삭제가 가능하게끔 변경했습니다.
유저 컴포넌트에서 session 정보 제공하기
/* provider.js 코드 */
'use client'
import { SessionProvider } from 'next-auth/react'
export default function Providers({ children }) {
return <SessionProvider>{children}</SessionProvider>
}
/* app 최상단 layout.js */
import Providers from "./providers";
<Providers>
<div className="navbar">
<div style={{ marginLeft: '20px', fontWeight: 'bold' }}>Jungle-Board</div>
<LogoutButton />
</div>
{children}
</Providers>
글 작성페이지 코드(유저 컴포넌트) 수정
import { useSession } from 'next-auth/react';
/* function 내부 */
const { data: session, status } = useSession();
const writer = session?.user.userId;
글 상세페이지 코드(서버 컴포넌트) 수정
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';
/* function 내부 */
const session = await getServerSession(authOptions);
/* return 내부 */
{
session && (session.user.userId == result.writer || session.user.role == 'admin') &&
<div className={styles.rightButtonBox}>
<DeleteButton id={_id} />
<Link href={`/edit/${_id}`} style={{ color: 'white', textDecoration: 'none' }}>
<button type="button" className={styles.corBtn}>수정</button>
</Link>
</div>
}
'크래프톤 정글 > Equipped in 정글(나만무)' 카테고리의 다른 글
[나만무] 타입스크립트(TS) 필수 지식 학습하기 by 코딩애플 (0) | 2025.06.27 |
---|---|
[나만무] Jungle-Board 댓글 기능 및 검색, 페이지네이션 구현 및 배포하기 (1) | 2025.06.20 |
[나만무] 06.17 TIL - Next.js 학습 by 코딩애플 (0) | 2025.06.17 |
[나만무] Jungle-Board 기본 CRUD 구현하기 (0) | 2025.06.17 |
[나만무] 06.16 TIL - Next.js 학습 by 코딩애플 (0) | 2025.06.16 |