Menu
CoddyTech

나만의 메모

Coddy Node.js 여정의 인증과 보안 섹션에 포함된 레슨. 24개 중 18번째.

로그인했다는 것은 네가 누구인지 증명할 뿐, 메모가 네 것이라는 뜻은 아니야. GET /notes/:id는 id만으로 메모를 찾기 때문에, 공유 링크나 로그에서 id를 알게 된 로그인 사용자라면 누구나 그 메모를 읽을 수 있어. 소유자를 확인하는 것을 권한 부여라고 해.

데이터베이스가 다른 사람의 문서를 반환하지 않도록 소유자를 쿼리 자체에 포함해. 문서를 찾을 수 없을 때는 403이 아니라 404로 응답해. 403으로 응답하면 방문자에게 메모가 존재한다는 사실을 알려 주게 돼.

const order = await orders.findOne({
    _id: new ObjectId(req.params.id),
    customer: req.user.id,
});
if (!order) {
    return res.status(404).json({ error: 'Order not found' });
}

사용자 한 명의 데이터에 접근하는 모든 쿼리에는 필터가 필요해. 읽기, 업데이트, 삭제가 모두 해당돼.

challenge icon

챌린지

쉬움

이 장에서는 2장의 세션 로그인으로 돌아가며, 서버는 더 커졌습니다. 이제 GET / 페이지와 GET /notes/:id 및 DELETE /notes/:id 경로, 그리고 관리자 계정이 있습니다. 두 /notes/:id 경로에는 허점이 있습니다. 노트를 id만으로 찾습니다. 로그인한 사용자라면 누구든 id를 알고 있으면 다른 사람의 노트를 읽거나 삭제할 수 있습니다. 이 허점을 막으세요.

  1. server.js의 GET /notes/:id에서 노트의 _id와 owner를 함께 사용해 찾으세요. 소유자는 로그인한 사용자, 즉 req.user.id여야 합니다.
  2. DELETE /notes/:id에서도 똑같이 하여, 아무도 자신의 소유가 아닌 노트를 삭제할 수 없게 하세요.
  3. 일치하는 노트가 없으면 두 경로 모두 이미 상태 코드 404와 {"error": "Note not found"}를 응답합니다. 다른 사람의 노트에 대해서도 이 응답을 유지하세요. 403을 응답하면 방문자에게 해당 노트가 존재한다고 알려 주게 됩니다.

직접 해보기: ‘플레이’를 누르세요.

  • POST /signup을 보낸 다음, POST /login을 보내세요. 둘 다 {"username": "ada", "password": "correct horse"}를 사용합니다.
  • {"text": "Safe code 4417"}를 사용해 POST /notes를 보내고, 응답에서 _id를 복사하세요. 그 id 뒤에 GET /notes/를 보내면 해당 노트가 반환됩니다.
  • {"username": "bob", "password": "correct horse"}로 가입하고 로그인하세요. ada의 id 뒤에 GET /notes/를 보내세요. 상태 코드 404와 {"error": "Note not found"}가 반환됩니다. 같은 id 뒤에 DELETE /notes/를 보내세요. 역시 404가 반환됩니다.
  • 첫 단계에서 사용한 본문으로 ada 계정에 다시 로그인하세요. ada의 노트는 여전히 있습니다. 이제 DELETE를 보내세요. 상태 코드 204가 반환됩니다.

직접 해보기

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.use(express.json());

// 유일한 관리자 계정입니다. 비밀번호는 환경에서 가져옵니다.
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, 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();
}

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) });
    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) });
    if (!deletedCount) {
        return res.status(404).json({ error: 'Note not found' });
    }
    res.status(204).end();
});

// 관리자용: 계정이 몇 개 있는지 보여줍니다.
app.get('/admin', requireUser, async (req, res) => {
    res.json({ users: await users.countDocuments() });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});
quiz icon실력 점검

이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.

인증과 보안의 모든 레슨

직접 연습해 보세요: Express 플레이그라운드