Menu
CoddyTech

انتهاء صلاحية الرمز

جزء من قسم المصادقة والأمان في رحلة Node.js على Coddy. الدرس 17 من 24.

لا يستطيع الخادم سحب الرمز: فلا يُخزَّن شيء، لذا يظل الرمز المسروق صالحًا حتى تنتهي صلاحيته. اجعل مدة صلاحية الرموز قصيرة، بالدقائق لا بالأيام، ودع العميل يطلب رمزًا جديدًا.

يرفض jwtVerify بالفعل أي رمز تجاوزت صلاحيته exp. تشير exp إلى لحظة محسوبة بالثواني منذ 1 يناير 1970. ويُطلق خطأً من النوع errors.JWTExpired. تكون err instanceof errors.JWTExpired صحيحةً لهذا النوع من الأخطاء، لذا يمكنك تمييز الرمز منتهي الصلاحية عن الرمز المزيف والإشارة إلى ذلك: فالعميل الذي يقرأ انتهت صلاحية الرمز يعرف أنه ينبغي له تسجيل الدخول مرة أخرى.

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' });
}
challenge icon

التحدي

سهل

لا يستطيع الخادم سحب الرمز، لذا يظل الرمز المسروق صالحًا إلى أن تنتهي صلاحيته. اجعل مدة صلاحية الرموز قصيرة، وأبلغ التطبيق بوضوح عند انتهاء صلاحية رمزه، حتى يعرف أنه عليه تسجيل الدخول مجددًا.

  1. في server.js، ضمن POST /token، اجعل صلاحية الرموز تنتهي بعد 15 دقيقة من إنشائها بدلًا من ساعة واحدة.
  2. في requireUser، افحص الخطأ الذي يطرحه jwtVerify. عند انتهاء صلاحية الرمز، تطرح jose خطأ errors.JWTExpired. استورد errors من jose. عندما يكون الخطأ من هذا النوع، أرسل استجابة بالحالة 401 و{"error": "Token expired"}.
  3. تظل كل المشكلات الأخرى تُرجع الحالة 401 و{"error": "Log in first"}: عدم وجود ترويسة، أو تغيير الرمز، أو توقيع الرمز بمفتاح آخر.

جرّب ذلك: اضغط على «العب». أرسل POST /signup، ثم POST /token، مع {"username": "ada", "password": "correct horse"} في كليهما. أرسل GET /inspect?token= متبوعًا بالرمز الجديد: أصبحت exp الآن بعد iat بـ 900 ثانية. كلاهما يحسب الثواني منذ 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');
});

جميع دروس المصادقة والأمان

تدرّب بنفسك: Playground لـ Express