السلسلة: هذا الدرس جزء من سلسلة NestJS 11. اقرأ المقال الرئيسي.
المصادقة والتفويض حاجزان يقرّران من يدخل API وما يحقّ له فعله. خلطهما الخطأ الأشيع. هذا الدرس يضع معمارية مفصولة وقابلة للتطوّر: Passport للمصادقة، JWT موقَّعة ومتداوَلة للجلسات، وCasbin كمحرّك قرارات خارجي للتفويض الدقيق.
المتطلبات
- API NestJS 11 مع Prisma 7 شغّال
- أساسيات hashing
- فهم تدفق JWT
- 90 دقيقة
الخطوة 1 — تثبيت Passport وJWT وCasbin
cd apps/api
pnpm add @nestjs/passport @nestjs/jwt passport passport-jwt argon2 casbin
pnpm add -D @types/passport-jwt
argon2 يبقى خوارزمية hashing الموصى بها من OWASP في 2026 — bcrypt مقبول لكن argon2id يقاوم GPU أفضل.
الخطوة 2 — module Auth وخدمة المصادقة
// auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwt: JwtService) {}
async login(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) throw new UnauthorizedException('Invalid credentials');
const ok = await argon2.verify(user.password, password);
if (!ok) throw new UnauthorizedException('Invalid credentials');
return this.issueTokens(user.id, user.role);
}
}
رسالة الخطأ تبقى متطابقة لـ «email غير معروف» و«password خاطئ» — كشف الفرق يسمح لمهاجم بتعداد الحسابات الصالحة. لا معلومة عن المستخدم تُضاف لـ JWT خارج id ورول.
الخطوة 3 — إصدار access tokens قصيرة وrefresh tokens متداولة
access token جانب client، JWT موقَّع صالح 15 دقيقة كحد أقصى. refresh token سرّ عشوائي مخزَّن في القاعدة hashed، صالح 7-30 يوم. token مسروق قابل للإلغاء على الخادم.
private async issueTokens(userId: string, role: Role) {
const accessToken = await this.jwt.signAsync(
{ sub: userId, role },
{ secret: process.env.JWT_SECRET, expiresIn: '15m' },
);
const refreshTokenRaw = randomBytes(48).toString('base64url');
const refreshTokenHash = createHash('sha256').update(refreshTokenRaw).digest('hex');
await this.prisma.refreshToken.create({
data: { userId, tokenHash: refreshTokenHash, expiresAt: addDays(new Date(), 7) },
});
return { accessToken, refreshToken: refreshTokenRaw };
}
التدوير عند كل استدعاء /auth/refresh: الـ hash القديم يُوسَم مُلغى، جديد يُدخَل. هذا التدوير يكشف السرقات: إن استخدم مهاجم refresh مسروقاً، الشرعي سيُلغى refresh خاصه صامتاً.
الخطوة 4 — ضبط استراتيجية JwtStrategy
// auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET!,
});
}
async validate(payload: { sub: string; role: Role }) {
return { id: payload.sub, role: payload.role };
}
}
الخطوة 5 — تعريف model Casbin لـ RBAC
# auth/casbin.model.conf
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act
Casbin يفصل model (نحو السياسة) وpolicy (قواعد ملموسة). دالة keyMatch2 تسمح بكتابة كائنات معلَّمة مثل /orders/:id التي تطابق /orders/123. القاعدة g تُعرّف تراتبية الأدوار.
الخطوة 6 — تثبيت policies في PostgreSQL
// auth/casbin.service.ts
@Injectable()
export class CasbinService implements OnModuleInit {
enforcer!: Enforcer;
async onModuleInit() {
const adapter = await PrismaAdapter.newAdapter(prisma);
this.enforcer = await newEnforcer('auth/casbin.model.conf', adapter);
}
async can(role: string, obj: string, act: string) {
return this.enforcer.enforce(role, obj, act);
}
}
الخطوة 7 — إنشاء PoliciesGuard NestJS
// auth/policies.guard.ts
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(private casbin: CasbinService) {}
async canActivate(ctx: ExecutionContext) {
const req = ctx.switchToHttp().getRequest();
const role = req.user.role;
const obj = req.route.path;
const act = req.method.toLowerCase();
return this.casbin.can(role, obj, act);
}
}
الـ guard يُسجَّل عالمياً بعد JwtAuthGuard. الترتيب حرج: نعرف أولاً من قبل اتخاذ قرار ماذا.
الخطوة 8 — Audit log وإلغاء
// admin/users.service.ts
async revoke(userId: string) {
await this.prisma.$transaction([
this.prisma.refreshToken.deleteMany({ where: { userId } }),
this.prisma.user.update({ where: { id: userId }, data: { locked: true } }),
this.prisma.auditLog.create({ data: { userId, action: 'REVOKE', byAdmin: true } }),
]);
}
العملية transactional: إن أخفقت كتابة audit log، العمليتان الأخريان تُلغيان. جانب JwtStrategy، إضافة استدعاء findUnique({ where: { id, locked: false }}) في validate يحجب tokens الوصول الصالحة لكن المُصدَرة قبل الإلغاء.
الخطوة 9 — اختبار السياسات
// auth/casbin.spec.ts
describe('CasbinService', () => {
it.each([
['ADMIN', '/orders/123', 'get', true],
['MEMBER', '/orders/123', 'delete', false],
['MEMBER', '/orders/me', 'get', true],
])('%s %s %s => %s', async (role, obj, act, expected) => {
expect(await casbin.can(role, obj, act)).toBe(expected);
});
});
الجدول it.each من Jest يُقرَأ كمصفوفة حقوق، ما يجعل diff git لتطوّر مقروءاً في ثانية.
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| JWT مقبول بعد logout | Stateless JWT غير قابل للإلغاء | refresh token متداول + جدول في القاعدة |
Brute force على /auth/login |
Rate limit غائب | @nestjs/throttler مع storage Redis |
| Casbin يُرجع دائماً false | Model .conf غير محمَّل عند boot |
تحقّق onModuleInit والسجلات |
| كلمة سرّ مُتحقَّق منها في O(n) | argon2.verify غير مستخدم |
دائماً argon2.verify(hash, plain) |
| تسرّب tokens في السجلات | Logger يُسلسل headers | Interceptor إخفاء أسرار |
أسئلة شائعة
لماذا ليس cookie HttpOnly بدل Authorization header؟ الاثنان صالحان. cookie HttpOnly يحمي من XSS لكن يُعرّض لـ CSRF — يلزم token CSRF إضافي. لـ SPA Next.js يستهلك نفس الأصل، cookie HttpOnly مع SameSite=Strict أأمن تسوية.
هل يمكن توقيع JWT بزوج RSA بدل سرّ HS256؟ نعم، موصى به بمجرّد أن تتحقق عدة خدمات من tokens. المفتاح الخاص يوقّع جانب Auth، العامّ يتحقّق جانب API.
كيف ندير 2FA TOTP؟ أضف حقل totpSecret مشفَّر على User، اعرض endpoint /auth/2fa/setup يُرجع QR code، وendpoint /auth/2fa/verify. مكتبة otplib تغطي تطبيق TOTP RFC 6238.
RBAC أم ABAC؟ RBAC يكفي طالما القرارات تعتمد فقط على الرول. بمجرّد ظهور قواعد سياقية (مؤلِّف الطلب، الساعة، الموقع الجغرافي)، انتقل إلى ABAC بإثراء طلب Casbin بسمات إضافية.