다중 사용자 인증 구현
이 토픽을 마치면
회원가입 시 비밀번호를 안전하게 해싱하고, bcrypt의 원리를 이해하며, 역할(role) 기반 접근 제어를 구현할 수 있습니다.
비밀번호를 그대로 저장하면 안 되는 이유
// NEVER do this
const users = [
{ username: 'alice', password: 'mySecret123' }
];데이터베이스가 유출되면 모든 사용자의 비밀번호가 노출됩니다. 2012년 LinkedIn 해킹, 2013년 Adobe 해킹 — 수억 건의 비밀번호가 평문(또는 약한 해시)으로 저장되어 있었습니다.
비밀번호는 절대 평문으로 저장하지 않습니다. 해싱(hashing)해서 저장합니다.
해싱이란
해싱은 단방향 변환입니다. 원본 → 해시는 가능하지만, 해시 → 원본은 불가능합니다.
"mySecret123" → 해싱 → "$2b$10$X7kG..." (가능)
"$2b$10$X7kG..." → ??? → "mySecret123" (불가능)로그인 시에는 입력된 비밀번호를 해싱해서 저장된 해시와 비교합니다.
사용자 입력: "mySecret123"
저장된 해시: "$2b$10$X7kG..."
"mySecret123" → 해싱 → "$2b$10$X7kG..." (일치 → 인증 성공)
"wrongPass" → 해싱 → "$2b$10$Qm1Y..." (불일치 → 인증 실패)bcrypt — 비밀번호 해싱의 표준
npm install bcryptbcrypt가 SHA-256 같은 일반 해시 함수보다 비밀번호에 적합한 이유:
- 느리게 설계됨: 해커가 초당 수십억 개의 해시를 시도하는 것을 방지
- Salt 자동 포함: 같은 비밀번호라도 매번 다른 해시가 생성됨
- 비용 조절 가능: rounds 값으로 속도/보안 트레이드오프 조절
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
// 해싱 (회원가입 시)
const hash = await bcrypt.hash('mySecret123', SALT_ROUNDS);
console.log(hash);
// $2b$10$X7kG.fDlUqVnPq5RvWEYq.ZM3Y4Fs8qD1vSrKLhwEcFjnp2fW9Xm.
// 비교 (로그인 시)
const isMatch = await bcrypt.compare('mySecret123', hash);
console.log(isMatch); // true
const isWrong = await bcrypt.compare('wrongPass', hash);
console.log(isWrong); // falseSALT_ROUNDS = 10은 2^10 = 1,024번 반복 연산을 의미합니다. 값이 높을수록 안전하지만 느립니다. 10~12가 현재 권장 범위입니다.
회원가입 구현
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const users = []; // 실제로는 DB 사용
app.post('/register', async (req, res) => {
const { username, password, displayName } = req.body;
// 입력 검증
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// 중복 확인
if (users.find(u => u.username === username)) {
return res.status(409).json({ error: 'Username already exists' });
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// 저장
const newUser = {
id: users.length + 1,
username,
password: hashedPassword,
displayName: displayName || username,
role: 'user',
createdAt: new Date().toISOString()
};
users.push(newUser);
res.status(201).json({
message: 'Registration successful',
user: { id: newUser.id, username: newUser.username }
});
});응답에 비밀번호(해시 포함)를 절대 포함하지 않습니다.
Passport와 연동
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy(
async (username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) {
return done(null, false, { message: 'User not found' });
}
// bcrypt로 비교
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Wrong password' });
}
return done(null, user);
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});이전 토픽의 Strategy에서 user.password !== password 대신 bcrypt.compare()를 사용하는 것이 유일한 차이입니다.
역할 기반 접근 제어 (RBAC)
function requireRole(...roles) {
return (req, res, next) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Login required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// 모든 로그인 사용자
app.get('/profile', requireRole('user', 'admin'), (req, res) => {
res.json({ user: req.user.displayName });
});
// 관리자만
app.get('/admin/users', requireRole('admin'), (req, res) => {
const safeUsers = users.map(u => ({
id: u.id, username: u.username, role: u.role
}));
res.json(safeUsers);
});
// 관리자 — 역할 변경
app.patch('/admin/users/:id/role', requireRole('admin'), (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
user.role = req.body.role;
res.json({ message: `${user.username} role changed to ${user.role}` });
});requireRole(...roles)은 고차 함수(함수를 반환하는 함수)입니다. 허용할 역할을 인자로 받아서, 해당 역할이 아니면 403을 반환하는 미들웨어를 만듭니다.
보안 체크리스트
| 항목 | 설명 |
|---|---|
| 비밀번호 해싱 | bcrypt, SALT_ROUNDS 10+ |
| 최소 길이 | 8자 이상 (NIST 권장) |
| 응답에 해시 미포함 | API 응답에 password 필드 절대 포함 금지 |
| 에러 메시지 일반화 | "잘못된 사용자명 또는 비밀번호" (어느 쪽이 틀렸는지 알려주지 않음) |
| Rate limiting | 로그인 시도 횟수 제한 (brute force 방지) |
| HTTPS | 비밀번호가 평문으로 네트워크를 타지 않도록 |
에러 메시지를 일반화하는 이유: "사용자명이 없습니다"라고 하면, 공격자가 존재하는 계정을 알아낼 수 있습니다. 이것을 **사용자 열거 공격(User Enumeration)**이라고 합니다.
실전 패턴 — 회원 탈퇴
app.delete('/account', requireRole('user', 'admin'), async (req, res) => {
const valid = await bcrypt.compare(req.body.password, req.user.password);
if (!valid) {
return res.status(401).json({ error: 'Password confirmation failed' });
}
const index = users.findIndex(u => u.id === req.user.id);
users.splice(index, 1);
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Account deleted' });
});
});계정 삭제는 위험한 작업이므로 비밀번호 재확인을 거칩니다. 삭제 후 반드시 req.logout()으로 세션을 정리합니다.
핵심 정리
| 개념 | 정리 |
|---|---|
| 해싱 | 단방향 변환. 원본 → 해시 가능, 해시 → 원본 불가능 |
| bcrypt | 비밀번호 전용 해시. 느리고 salt 포함 |
| Salt | 같은 비밀번호도 다른 해시를 생성하는 랜덤 값 |
| RBAC | 역할(role) 기반으로 접근 권한을 분리 |
bcrypt.hash() | 회원가입 시 비밀번호 → 해시 |
bcrypt.compare() | 로그인 시 입력 vs 저장된 해시 비교 |
인증은 "비밀번호가 맞는가"를 넘어서 "비밀번호를 어떻게 안전하게 다루는가"가 핵심입니다. 평문 저장은 사고, 약한 해시(MD5, SHA-1)는 무의미, bcrypt는 현재 표준입니다.