תפקידי מנהל
חלק מהיחידה אימות ואבטחה במסלול ה-Node.js של Coddy. שיעור 19 מתוך 24.
GET /admin רק בודק שמישהו מחובר, ולכן כל החברים יכולים לראות אותו. תפקיד שמאוחסן אצל המשתמש, כמו 'member' או 'admin', מציין מה מותר לו לעשות. מפעל תווכה, פונקציה שמחזירה תווכה, בודק תפקיד אחד לכל נתיב:
function requirePlan(plan) {
return (req, res, next) => {
if (req.user.plan !== plan) {
return res.status(402).json({ error: `Needs the ${plan} plan` });
}
next();
};
}
app.get('/exports', requireUser, requirePlan('pro'), sendExports);התפקיד תמיד נקבע על ידי השרת. בעת ההרשמה מועתקים רק השדות שהשרת מצפה להם מתוך req.body, ולכן מבקר ששולח "role": "admin" לא מקבל מזה דבר.
אתגר
קלGET /admin מציג כמה חשבונות קיימים, וכרגע כל חבר מחובר יכול לראות זאת. רק מנהלי מערכת אמורים לראות זאת. כל משתמש מקבל תפקיד, שהשרת קובע, והנתיב בודק אותו.
- ב־
server.js, בתוךPOST /signup, שמור כל משתמש חדש עם"role": "member". לעולם אל תיקח את התפקיד מגוף הבקשה: מבקר ששולח"role": "admin"עדיין הופך לחבר. - כתוב פונקציה
requireRole(role)שמחזירה middleware. ה־middleware הזה בודק אתreq.user.role:- אם הוא אינו
role, השב עם סטטוס 403 ועם{"error": "Not allowed"}. משמעות 403 Forbidden היא שהשרת יודע מי את/ה, אבל אסור לך לבצע את הפעולה הזאת. משמעות 401 היא שהוא לא יודע מי את/ה. - אם הוא
role, קרא ל־next().
- אם הוא אינו
- הוסף את
requireRole('admin')אלGET /admin, אחריrequireUser. הוא קורא אתreq.user, שרקrequireUserמגדירה.
השרת כבר יוצר חשבון מנהל מערכת אחד כשהוא מופעל. שם המשתמש שלו הוא admin, והסיסמה שלו היא admin-pass-for-tests, שמגיעה ממשתנה הסביבה ADMIN_PASSWORD.
נסה/י זאת: לחץ/י על ״שחק״. שלח/י GET /admin: סטטוס 401 ו־{"error": "Log in first"}. עכשיו משתמשת בשם mallory מנסה להפוך את עצמה למנהלת מערכת. שלח/י POST /signup עם {"username": "mallory", "password": "correct horse", "role": "admin"}, ואז POST /login עם {"username": "mallory", "password": "correct horse"}. שלח/י GET /admin: סטטוס 403 ו־{"error": "Not allowed"}, כי השדה הנוסף לא הפך אותה למנהלת מערכת. שלח/י POST /login עם {"username": "admin", "password": "admin-pass-for-tests"}, ואז GET /admin: {"users": 2}.
נסו בעצמכם
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), 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, async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
כל השיעורים ביחידה אימות ואבטחה
תרגלו בעצמכם: Express אונליין