תפוגת אסימונים
חלק מהיחידה אימות ואבטחה במסלול ה-Node.js של Coddy. שיעור 17 מתוך 24.
שרת לא יכול לבטל אסימון: שום דבר לא נשמר, ולכן אסימון שנגנב ממשיך לעבוד עד שתוקפו פג. הקפד שהאסימונים יהיו קצרי־מועד, למשך דקות ולא ימים, ואפשר ללקוח לבקש אסימון חדש.
jwtVerify כבר דוחה אסימון שהערך exp שלו עבר. exp הוא רגע בזמן שנמדד בשניות מאז 1 בינואר 1970. הוא זורק שגיאה מסוג errors.JWTExpired. הביטוי err instanceof errors.JWTExpired מחזיר true עבור סוג כזה של שגיאה, כך שאפשר להבחין בין אסימון שתוקפו פג לבין אסימון מזויף ולציין זאת: לקוח שקורא ״תוקף האסימון פג״ יודע שעליו להתחבר שוב.
import { errors, jwtVerify } from 'jose';
try {
await jwtVerify(inviteToken, inviteKey);
} catch (err) {
if (err instanceof errors.JWTExpired) {
return res.status(410).json({ error: 'This invite has run out' });
}
return res.status(400).json({ error: 'Not a real invite' });
}אתגר
קלשרת לא יכול לבטל אסימון, ולכן אסימון שנגנב ממשיך לפעול עד שהוא פג. הגדירו לאסימונים משך חיים קצר, והודיעו לאפליקציה בבירור כשהאסימון שלה פג, כדי שהיא תדע שעליה להתחבר שוב.
- ב־
server.js, בתוךPOST /token, הגדירו שהאסימונים יפוגו 15 דקות לאחר יצירתם, במקום לאחר שעה. - בתוך
requireUser, בדקו איזו שגיאהjwtVerifyזורקת. במקרה של אסימון שפג,joseזורקת שגיאתerrors.JWTExpired. ייבאו אתerrorsמ־jose. כשהשגיאה היא מהסוג הזה, החזירו סטטוס 401 ואת{"error": "Token expired"}. - בכל בעיה אחרת עדיין יש להחזיר סטטוס 401 ואת
{"error": "Log in first"}: אין כותרת, האסימון השתנה או שהאסימון נחתם במפתח אחר.
נסו זאת: לחצו על Run. שלחו POST /signup, ואז POST /token, ובשניהם השתמשו ב־{"username": "ada", "password": "correct horse"}. שלחו GET /inspect?token= ואחריו האסימון החדש: כעת exp הוא 900 שניות אחרי iat. שניהם מודדים שניות מאז 1 בינואר 1970. כעת, הנה אסימון שהמפתח הסודי של השרת הזה חתם עליו, והוא פג ב־1 בינואר 2026: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE בשדה Headers, הגדירו את Authorization לערך Bearer, רווח ואז האסימון הזה, ולאחר מכן שלחו GET /me: סטטוס 401 ואת {"error": "Token expired"}. שנו אות אחת בחלק האמצעי שלו ושלחו שוב: סטטוס 401 ואת {"error": "Log in first"}. אסימון שהשתנה נכשל בבדיקת החתימה לפני שקוראים את התאריך שלו.
נסו בעצמכם
import express from 'express';
import { SignJWT, decodeJwt, jwtVerify } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const app = express();
app.use(express.json());
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' });
}
res.json({ username: user.username });
});
app.post('/token', 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 token = await new SignJWT({ username: user.username })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user._id.toHexString())
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
res.json({ token });
});
// בודק את הטוקן בכותרת Authorization, או מחזיר 401.
async function requireUser(req, res, next) {
const token = (req.get('authorization') ?? '').replace(/^Bearer /, '');
let payload;
try {
({ payload } = await jwtVerify(token, secret));
} catch {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: payload.sub, username: payload.username };
next();
}
app.get('/me', requireUser, (req, res) => {
res.json(req.user);
});
// מציג את תוכן הטוקן: GET /inspect?token=...
app.get('/inspect', (req, res) => {
try {
res.json(decodeJwt(String(req.query.token ?? '')));
} catch {
res.status(400).json({ error: 'Not a JWT' });
}
});
// עמוד הניהול. אין לסמוך על אף טענה לפני שבודקים את החתימה שלה.
app.get('/admin', (req, res) => {
res.status(403).json({ error: 'Admins only' });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
כל השיעורים ביחידה אימות ואבטחה
תרגלו בעצמכם: Express אונליין