728x90
반응형
1. 인증과 인가의 차이
1.1 인증(Authentication)
인증은 사용자가 누구인지 확인하는 과정입니다. "너는 누구니?"라는 질문에 답합니다.
인증 방법:
- 아이디/비밀번호
- 소셜 로그인 (Google, GitHub 등)
- 생체 인증
- 인증서
- OTP (일회용 비밀번호)
1.2 인가(Authorization)
인가는 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정입니다. "너는 이걸 할 수 있니?"라는 질문에 답합니다.
인가 예시:
- 관리자만 사용자 삭제 가능
- 작성자만 글 수정 가능
- 유료 회원만 프리미엄 콘텐츠 접근 가능
2. 기본 인증 구현
2.1 비밀번호 해싱
npm install bcrypt
const bcrypt = require('bcrypt');
// 비밀번호 해싱
async function hashPassword(password) {
const saltRounds = 10;
return await bcrypt.hash(password, saltRounds);
}
// 비밀번호 검증
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// 사용 예시
async function registerUser(email, password) {
const hashedPassword = await hashPassword(password);
const user = {
email,
password: hashedPassword,
createdAt: new Date()
};
// 데이터베이스에 저장
await saveUser(user);
return { id: user.id, email: user.email };
}
async function loginUser(email, password) {
const user = await findUserByEmail(email);
if (!user) {
throw new Error('User not found');
}
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
throw new Error('Invalid password');
}
return user;
}
2.2 Express 인증 미들웨어
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const SECRET = 'your-secret-key';
const users = new Map();
// 회원가입
app.post('/register', async (req, res) => {
try {
const { email, password, name } = req.body;
// 이메일 중복 확인
if (users.has(email)) {
return res.status(400).json({ error: 'Email already exists' });
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 저장
const user = {
id: Date.now().toString(),
email,
password: hashedPassword,
name,
role: 'user'
};
users.set(email, user);
res.status(201).json({
message: 'User registered',
user: { id: user.id, email, name }
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 로그인
app.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
const user = users.get(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// JWT 토큰 생성
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: '1h' }
);
res.json({ token, user: { id: user.id, email, name: user.name } });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(3000);
3. 인증 미들웨어
// 인증 확인 미들웨어
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// 보호된 라우트
app.get('/profile', authenticate, (req, res) => {
res.json({ user: req.user });
});
app.get('/users', authenticate, (req, res) => {
const allUsers = Array.from(users.values()).map(u => ({
id: u.id,
email: u.email,
name: u.name
}));
res.json(allUsers);
});
반응형
4. 역할 기반 인가 (RBAC)
// 역할 정의
const ROLES = {
ADMIN: 'admin',
MODERATOR: 'moderator',
USER: 'user'
};
// 권한 정의
const PERMISSIONS = {
[ROLES.ADMIN]: ['read', 'write', 'delete', 'manage_users'],
[ROLES.MODERATOR]: ['read', 'write', 'delete'],
[ROLES.USER]: ['read', 'write']
};
// 권한 확인 미들웨어
function authorize(...allowedRoles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// 특정 권한 확인
function hasPermission(permission) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const userPermissions = PERMISSIONS[req.user.role] || [];
if (!userPermissions.includes(permission)) {
return res.status(403).json({ error: 'Permission denied' });
}
next();
};
}
// 사용
app.delete('/users/:id',
authenticate,
authorize(ROLES.ADMIN),
(req, res) => {
// 관리자만 사용자 삭제 가능
res.json({ message: 'User deleted' });
}
);
app.post('/posts',
authenticate,
hasPermission('write'),
(req, res) => {
// write 권한이 있는 사용자만 게시글 작성 가능
res.json({ message: 'Post created' });
}
);
5. 리소스 기반 인가
// 게시글 소유자 확인
async function isOwner(req, res, next) {
const postId = req.params.id;
const post = await findPostById(postId);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.authorId !== req.user.userId) {
return res.status(403).json({ error: 'Not authorized' });
}
req.post = post;
next();
}
// 소유자 또는 관리자 확인
async function isOwnerOrAdmin(req, res, next) {
const postId = req.params.id;
const post = await findPostById(postId);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
const isOwner = post.authorId === req.user.userId;
const isAdmin = req.user.role === ROLES.ADMIN;
if (!isOwner && !isAdmin) {
return res.status(403).json({ error: 'Not authorized' });
}
req.post = post;
next();
}
// 사용
app.put('/posts/:id',
authenticate,
isOwner,
(req, res) => {
// 작성자만 수정 가능
res.json({ message: 'Post updated' });
}
);
app.delete('/posts/:id',
authenticate,
isOwnerOrAdmin,
(req, res) => {
// 작성자 또는 관리자만 삭제 가능
res.json({ message: 'Post deleted' });
}
);
6. 정책 기반 인가
// 정책 정의
const policies = {
'post:read': (user, post) => true,
'post:update': (user, post) => {
return user.id === post.authorId || user.role === 'admin';
},
'post:delete': (user, post) => {
return user.id === post.authorId || user.role === 'admin';
},
'user:manage': (user) => {
return user.role === 'admin';
}
};
// 정책 확인 함수
function can(user, action, resource = null) {
const policy = policies[action];
if (!policy) {
return false;
}
return policy(user, resource);
}
// 미들웨어
function authorize(action, getResource = null) {
return async (req, res, next) => {
let resource = null;
if (getResource) {
resource = await getResource(req);
}
if (!can(req.user, action, resource)) {
return res.status(403).json({ error: 'Forbidden' });
}
req.resource = resource;
next();
};
}
// 사용
app.put('/posts/:id',
authenticate,
authorize('post:update', async (req) => {
return await findPostById(req.params.id);
}),
(req, res) => {
res.json({ message: 'Updated' });
}
);
7. 토큰 갱신
const REFRESH_SECRET = 'refresh-secret';
// 로그인 시 액세스 토큰과 리프레시 토큰 발급
app.post('/login', async (req, res) => {
// ... 인증 로직
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// 리프레시 토큰 저장 (DB에 저장 권장)
refreshTokens.set(refreshToken, user.id);
res.json({ accessToken, refreshToken });
});
// 토큰 갱신
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken || !refreshTokens.has(refreshToken)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const user = findUserById(decoded.userId);
const newAccessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// 로그아웃
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.json({ message: 'Logged out' });
});
8. 보안 강화
8.1 Rate Limiting
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// 로그인 시도 제한
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 5, // 최대 5회
message: { error: 'Too many login attempts, try again later' }
});
app.post('/login', loginLimiter, async (req, res) => {
// 로그인 로직
});
// 전역 제한
const globalLimiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 100 // 최대 100회
});
app.use(globalLimiter);
8.2 보안 헤더
npm install helmet
const helmet = require('helmet');
app.use(helmet());
8.3 입력 검증
const { body, validationResult } = require('express-validator');
app.post('/register',
body('email').isEmail().normalizeEmail(),
body('password')
.isLength({ min: 8 })
.matches(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])/)
.withMessage('Password must contain letters, numbers, and special characters'),
body('name').trim().isLength({ min: 2, max: 50 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// 회원가입 로직
}
);
9. Passport.js
npm install passport passport-local passport-jwt
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
// 로컬 전략 (이메일/비밀번호)
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
try {
const user = await findUserByEmail(email);
if (!user) {
return done(null, false, { message: 'User not found' });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Invalid password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// JWT 전략
passport.use(new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: SECRET
},
async (payload, done) => {
try {
const user = await findUserById(payload.userId);
if (!user) {
return done(null, false);
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
app.use(passport.initialize());
// 로그인
app.post('/login',
passport.authenticate('local', { session: false }),
(req, res) => {
const token = jwt.sign({ userId: req.user.id }, SECRET);
res.json({ token });
}
);
// 보호된 라우트
app.get('/profile',
passport.authenticate('jwt', { session: false }),
(req, res) => {
res.json({ user: req.user });
}
);
결론
인증은 사용자 신원 확인, 인가는 권한 확인입니다. bcrypt로 비밀번호를 해싱하고, JWT로 상태 비저장 인증을 구현합니다. 역할 기반(RBAC) 또는 정책 기반 인가로 세밀한 접근 제어를 합니다. Rate limiting, helmet, 입력 검증으로 보안을 강화하고, Passport.js로 다양한 인증 전략을 쉽게 구현할 수 있습니다.
728x90
반응형
'Node.js' 카테고리의 다른 글
| Node.js의 웹소켓(WebSocket) 사용법 (0) | 2026.03.13 |
|---|---|
| Node.js의 GraphQL API 만들기 (0) | 2026.03.12 |
| Node.js의 RESTful API 만들기 (0) | 2026.03.12 |
| Node.js의 서버 사이드 렌더링(SSR) (0) | 2026.03.11 |
| Node.js의 Fastify 프레임워크 (1) | 2026.03.11 |