로그인 횟수 제한
Coddy Node.js 여정의 인증과 보안 섹션에 포함된 레슨. 24개 중 23번째.
스크립트는 하나의 계정에 분당 수천 개의 비밀번호를 시도할 수 있습니다. 사용자 이름별로 로그인 실패 횟수를 세고, 1분 안에 5회 실패하면 그 1분이 지날 때까지 더 이상 확인하지 않도록 거부하세요.
상태 코드 429 Too Many Requests는 이유를 알려 주고, Retry-After 헤더는 몇 초 동안 기다려야 하는지 알려 줍니다. 실패 횟수만 세세요. 올바른 비밀번호를 입력한 사람이 자기 계정에 접근하지 못하게 되는 일은 절대로 없어야 합니다. Map은 키를 기준으로 값을 저장합니다. map.get(key)은 값을 읽고 map.set(key, value)는 값을 씁니다. 메모리에 저장되므로 서버 하나에서 사용하기에는 충분하지만, 서버가 재시작되면 내용이 사라집니다. 이 예제에서는 1분에 비밀번호 재설정 이메일을 한 통만 허용하므로 시간만 저장합니다. 로그인에서는 실패 횟수를 세므로 기록에 횟수와 시간이 저장됩니다.
const lastSent = new Map();
app.post('/reset', (req, res) => {
const email = String(req.body.email ?? '').toLowerCase();
const wait = (lastSent.get(email) ?? 0) + 60_000 - Date.now();
if (wait > 0) {
res.set('Retry-After', String(Math.ceil(wait / 1000)));
return res.status(429).json({ error: 'One reset email a minute' });
}
lastSent.set(email, Date.now());
// ... 이메일 보내기
});이 학습용 버전은 단일 서버 프로세스에서 완료된 실패 횟수를 셉니다. 비밀번호 확인이 진행 중일 때 요청이 동시에 들어오면 제한 횟수를 초과할 수 있습니다. 실제 서비스에서 사용할 제한 기능은 비밀번호를 확인하기 전에 시도를 집계하고, 모든 서버가 공유하는 저장소에 횟수를 보관해야 합니다.
챌린지
쉬움이 단계에서는 Security Headers의 프로젝트 파일(server.js 포함)이 아니라, 강의용으로 준비된 비공개 노트 API 프로젝트를 엽니다. 이전 작업은 해당 강의에 그대로 남아 있습니다. server.js를 수정하기 전에 제공된 파일을 읽어 보세요. 이 사본에는 비공개 노트의 소유자 필터, 역할 검사, 보안 헤더가 포함되어 있습니다.
스크립트는 계정 하나를 대상으로 1분에 수천 개의 비밀번호를 시도할 수 있습니다. 속도를 늦추세요. 1분 안에 사용자 이름 하나에 대해 비밀번호를 5번 잘못 입력하면, 그 1분이 지날 때까지 해당 이름의 비밀번호 검사를 중단하세요.
server.js에서POST /login위에 사용자 이름별 로그인 실패 기록을Map에 저장하세요. 각 이름에 대해 실패 횟수와 첫 실패 시각을 저장합니다.POST /login에서 비밀번호를 확인하기 전에 사용자 이름의 기록을 조회하세요. 첫 실패 후 1분 이상 지난 기록은 기록이 없는 것으로 간주합니다. 1분 안에 해당 이름으로 5번 실패했다면 상태 코드 429와{"error": "Too many failed logins. Try again later."}를 응답하세요. 비밀번호가 맞더라도 이렇게 하세요. 429 Too Many Requests는 “속도를 늦추세요”라는 뜻입니다.- 429 응답을 보낼 때, 첫 실패 후 1분이 지날 때까지 남은 초를 정수로 헤더
Retry-After에 설정하세요. 예를 들면Retry-After: 42입니다. - 비밀번호가 틀리면 해당 사용자 이름의 실패 횟수에 1을 더한 다음, 일반적인 401 응답을 보내세요. 첫 실패이거나 이전 기록이 만료된 후의 첫 실패라면 현재 시각을 기록하고 횟수를 1로 새로 시작합니다.
- 로그인에 성공한 경우는 절대 횟수에 포함하지 마세요. 한 사용자 이름의 실패 횟수가 다른 사용자 이름을 제한하지 않도록 하세요.
직접 해 보기: ‘실행’을 누르세요.
POST /signup에{"username": "ada", "password": "correct horse"}를 전송하세요.POST /login에{"username": "ada", "password": "guess"}를 5번 전송하세요. 매번 상태 코드는 401입니다.- 여섯 번째로 전송하세요. 상태 코드는 429입니다. Headers 탭에 초 단위 숫자와 함께
Retry-After가 표시됩니다. 숫자는 최대 60입니다. - 올바른 비밀번호
{"username": "ada", "password": "correct horse"}를 전송하세요. 여전히 429입니다. POST /signup에 이어POST /login에{"username": "bob", "password": "correct horse"}를 전송하세요. ada만 제한되어 있으므로 상태 코드는 200입니다. 1분이 지나면 ada도 다시 로그인할 수 있습니다.
직접 해보기
import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// 브라우저가 모든 응답을 신중하게 처리하도록 알리는 헤더.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// 유일한 관리자 계정. 비밀번호는 환경에서 가져옵니다.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
const sid = randomBytes(24).toString('base64url');
await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ username: user.username });
});
// 세션 쿠키에 해당하는 사용자를 찾거나 401로 응답합니다.
async function requireUser(req, res, next) {
const { sid } = parseCookies(req.get('cookie'));
const session = sid && (await sessions.findOne({ _id: sid }));
const user = session && (await users.findOne({ _id: session.userId }));
if (!user) {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
next();
}
// 로그인한 사용자에게 이 역할이 없으면 403으로 응답합니다.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Not allowed' });
}
next();
};
}
app.get('/me', requireUser, (req, res) => {
res.json({ username: req.user.username });
});
app.post('/logout', async (req, res) => {
const { sid } = parseCookies(req.get('cookie'));
if (sid) {
await sessions.deleteOne({ _id: sid });
}
res.clearCookie('sid');
res.status(204).end();
});
// 내 노트를 페이지로 보여줍니다.
app.get('/', requireUser, async (req, res) => {
const list = await notes.find({ owner: req.user.id }).toArray();
const items = list.map((note) => `<li>${note.text}</li>`).join('');
res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});
// 노트: 각 사용자는 자신의 노트만 읽고 씁니다.
app.get('/notes', requireUser, async (req, res) => {
res.json(await notes.find({ owner: req.user.id }).toArray());
});
app.post('/notes', requireUser, async (req, res) => {
const note = { owner: req.user.id, text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.get('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
app.delete('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!deletedCount) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).end();
});
// 관리자용: 계정이 몇 개 있는지 확인합니다.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
인증과 보안의 모든 레슨
직접 연습해 보세요: Express 플레이그라운드