diff --git a/BackendAcademy/package.json b/BackendAcademy/package.json index 964b4d9d7..bfc64a895 100644 --- a/BackendAcademy/package.json +++ b/BackendAcademy/package.json @@ -24,6 +24,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.4", "file-type": "^19.6.0", + "ioredis": "^5.11.1", "joi": "^18.0.2", "multer": "^2.2.0", "prom-client": "^15.1.3", diff --git a/BackendAcademy/src/admin/admin.controller.ts b/BackendAcademy/src/admin/admin.controller.ts index ce22ad662..f79bb112b 100644 --- a/BackendAcademy/src/admin/admin.controller.ts +++ b/BackendAcademy/src/admin/admin.controller.ts @@ -1,8 +1,10 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { AdminService } from './admin.service'; import { LocalizationService } from '../i18n/localization.service'; +import { JwtAdminGuard, RolesGuard, Roles, UserRole } from '../auth'; @Controller('admin') +@UseGuards(JwtAdminGuard, RolesGuard) export class AdminController { constructor( private readonly adminService: AdminService, @@ -10,6 +12,7 @@ export class AdminController { ) {} @Get('analytics/summary') + @Roles(UserRole.ADMIN) async getDashboardSummary() { const summary = await this.adminService.getDashboardSummary(); return { @@ -23,4 +26,4 @@ export class AdminController { data: summary, }; } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/admin/admin.module.ts b/BackendAcademy/src/admin/admin.module.ts index 499c2f551..30dda9c93 100644 --- a/BackendAcademy/src/admin/admin.module.ts +++ b/BackendAcademy/src/admin/admin.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { SubmissionsModule } from '../submissions/submissions.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [SubmissionsModule], + imports: [SubmissionsModule, AuthModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts index c57790129..80f68c739 100644 --- a/BackendAcademy/src/ai/ai.service.ts +++ b/BackendAcademy/src/ai/ai.service.ts @@ -350,59 +350,6 @@ export class AiService { ); } - - async getRecommendation(userId: string): Promise { - const snapshot = this.redisService - ? await this.redisService.getUserSnapshot(userId) - : null; - - if (!snapshot) { - return { - userId, - recommendations: [], - explainability: { - factors: ['insufficient_data'], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - - const explainability = this.redisService - ? await this.redisService.getRecommendationExplainability(userId) - : null; - - const recommendedCourses = snapshot.recentCourses.length > 0 - ? snapshot.recentCourses.slice(0, 3) - : ['rust-fundamentals', 'smart-contracts-101', 'stellar-basics']; - - const recommendations = recommendedCourses.map((courseId, index) => ({ - courseId, - score: Math.max(0, 1 - index * 0.2 - (snapshot.interactionCount > 0 ? 0 : 0.3)), - reason: explainability?.factors[index] || 'course_popularity', - })); - - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('recommendation_generated', 'ai'); - } - - return { - userId, - recommendations, - explainability: explainability || { - factors: [], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - const lines = code.split('\n').filter((l) => l.trim().length > 0).length; const hasComments = code.includes('//') || code.includes('/*'); const hasFunctions = code.includes('fn '); @@ -466,6 +413,58 @@ export class AiService { }; } + async getRecommendation(userId: string): Promise { + const snapshot = this.redisService + ? await this.redisService.getUserSnapshot(userId) + : null; + + if (!snapshot) { + return { + userId, + recommendations: [], + explainability: { + factors: ['insufficient_data'], + confidence: 0.1, + userSignalAge: 0, + signalsUsed: [], + modelVersion: 'rustacademy-recommender-v2', + }, + generatedAt: new Date(), + }; + } + + const explainability = this.redisService + ? await this.redisService.getRecommendationExplainability(userId) + : null; + + const recommendedCourses = snapshot.recentCourses.length > 0 + ? snapshot.recentCourses.slice(0, 3) + : ['rust-fundamentals', 'smart-contracts-101', 'stellar-basics']; + + const recommendations = recommendedCourses.map((courseId, index) => ({ + courseId, + score: Math.max(0, 1 - index * 0.2 - (snapshot.interactionCount > 0 ? 0 : 0.3)), + reason: explainability?.factors[index] || 'course_popularity', + })); + + if (this.monitoringService) { + this.monitoringService.recordDomainEvent('recommendation_generated', 'ai'); + } + + return { + userId, + recommendations, + explainability: explainability || { + factors: [], + confidence: 0.1, + userSignalAge: 0, + signalsUsed: [], + modelVersion: 'rustacademy-recommender-v2', + }, + generatedAt: new Date(), + }; + } + // ────────────────────────────────────────────────────────────────── // Chat history management (#372, #373) // ────────────────────────────────────────────────────────────────── diff --git a/BackendAcademy/src/analytics/analytics.service.ts b/BackendAcademy/src/analytics/analytics.service.ts index d2f41a320..6e66eb4a3 100644 --- a/BackendAcademy/src/analytics/analytics.service.ts +++ b/BackendAcademy/src/analytics/analytics.service.ts @@ -404,7 +404,6 @@ export class AnalyticsService { totalDiscrepanciesFound: totalDiscrepancies, }; } -} // ── Notification batching analytics (#386) ──────────────── diff --git a/BackendAcademy/src/assets/assets.module.ts b/BackendAcademy/src/assets/assets.module.ts index 62efe6108..3516bf81b 100644 --- a/BackendAcademy/src/assets/assets.module.ts +++ b/BackendAcademy/src/assets/assets.module.ts @@ -1,7 +1,7 @@ -import {'Module' } from '@nestjs/common'; -import {'AssetsController' } from './assets.controller'; -import {'AssetsService' } from './assets.service'; -import {'SecurityModule' } from '../security/security.module'; +import { Module } from '@nestjs/common'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; +import { SecurityModule } from '../security/security.module'; /** * Module exposing asset upload, metadata, download, and delete endpoints diff --git a/BackendAcademy/src/audit/audit.service.ts b/BackendAcademy/src/audit/audit.service.ts index f5b8c93ec..adffdc892 100644 --- a/BackendAcademy/src/audit/audit.service.ts +++ b/BackendAcademy/src/audit/audit.service.ts @@ -39,7 +39,7 @@ export class AuditLogService { // Provide structured output to the standard logger without secrets this.logger.log( - `Audit: ${event.action} | Actor: ${event.actor ?? '-'} | Outcome: ${event.outcome} | Session: ${event.session ?? '-'} | Correlation: ${correlation}`, +`Audit: ${event.action} | Actor: ${event.actor ?? '-'} | Outcome: ${event.outcome} | Session: ${event.session ?? '-'} | Correlation: ${correlation}`, ); return log; diff --git a/BackendAcademy/src/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts index fe5ef4e39..1c180d299 100644 --- a/BackendAcademy/src/auth/auth-session.controller.ts +++ b/BackendAcademy/src/auth/auth-session.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - Delete, Get, HttpCode, HttpStatus, @@ -68,9 +67,9 @@ export class AuthSessionController { @Get(':userId') @HttpCode(HttpStatus.OK) - getActiveSessions( + async getActiveSessions( @Param('userId') userId: string, - ): Omit[] { + ): Promise[]> { return this.authSessionService.getActiveSessions(userId); } diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts index 7663ffd13..872ae0dcd 100644 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ b/BackendAcademy/src/auth/auth-session.service.ts @@ -1,10 +1,8 @@ -### BackendAcademy/src/auth/auth-session.service.ts - -import { +import { Injectable, UnauthorizedException, Logger, - Inject, + Optional, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuditLogService } from '../audit/audit.service'; @@ -19,48 +17,65 @@ import { } from './interfaces/session.interface'; import { RedisService } from '../redis/redis.service'; +/** + * #350: Centralized session policy configuration. + * All session-related durations and rules are defined in one place + * so they can be enforced consistently across web and mobile clients. + */ export interface SessionPolicy { + /** Access token TTL in seconds (default: 15 min). */ accessTokenTtl: number; + /** Refresh token TTL in seconds (default: 7 days). */ refreshTokenTtl: number; + /** Grace period after refresh token expiry for delivery delays (seconds). */ deliveryGracePeriod: number; + /** Maximum number of concurrent sessions per user. */ maxConcurrentSessions: number; + /** Whether to enforce single-session mode (logout other sessions on new login). */ singleSessionMode: boolean; + /** Whether to require device fingerprint for new sessions. */ requireDeviceFingerprint: boolean; + /** Duration in seconds after which idle sessions are revoked. */ idleSessionTimeout: number; } const DEFAULT_SESSION_POLICY: SessionPolicy = { - accessTokenTtl: 900, - refreshTokenTtl: 604_800, - deliveryGracePeriod: 300, + accessTokenTtl: 900, // 15 minutes + refreshTokenTtl: 604_800, // 7 days + deliveryGracePeriod: 300, // 5 minutes grace for email delivery maxConcurrentSessions: 5, singleSessionMode: false, requireDeviceFingerprint: false, - idleSessionTimeout: 86_400, + idleSessionTimeout: 86400, // 24 hours }; +/** + * AuthSessionService - Issue #220, #349, #350 + * + * Provides secure session management with: + * - Short-lived access tokens (JWT, default 15 min) + * - Long-lived refresh tokens (JWT, default 7 days + 5 min grace period) + * - Refresh-token rotation: every refresh revokes the old token and + * issues a fresh pair, preventing replay attacks. + * - Session revocation on logout (single session) or logout-all (all + * sessions belonging to a user). + * - Centralized session policy (#350) for consistent web/mobile behavior. + * - Delivery grace period (#349) for password reset tokens. + * + * Sessions are stored in Redis to persist across restarts and share across multiple instances. + */ @Injectable() export class AuthSessionService { private readonly logger = new Logger(AuthSessionService.name); - /** - * #350: Centralized session policy - */ private readonly sessionPolicy: SessionPolicy; - private readonly refreshLocks = new Map>(); - - private readonly accessSecret: string; - private readonly refreshSecret: string; - private readonly auditService: AuditLogService; constructor( private readonly jwtService: JwtService, private readonly configService: ConfigService, - @Inject(RedisService) private readonly redis: RedisService, - @Inject('REDIS_CLIENT') private readonly redisClient: Redis, - private readonly auditService: AuditLogService, + @Optional() private readonly redis: RedisService, + @Optional() private readonly auditService?: AuditLogService, ) { - this.redis = redisClient; // #350: Load centralized session policy from config this.sessionPolicy = { accessTokenTtl: this.configService.get('SESSION_ACCESS_TOKEN_TTL', DEFAULT_SESSION_POLICY.accessTokenTtl), @@ -71,9 +86,6 @@ export class AuthSessionService { requireDeviceFingerprint: this.configService.get('SESSION_REQUIRE_DEVICE', DEFAULT_SESSION_POLICY.requireDeviceFingerprint), idleSessionTimeout: this.configService.get('SESSION_IDLE_TIMEOUT', DEFAULT_SESSION_POLICY.idleSessionTimeout), }; - - this.accessSecret = this.configService.get('JWT_REFRESH_SECRET', 'default-refresh-secret'); } private hashToken(token: string): string { @@ -81,9 +93,8 @@ export class AuthSessionService { } // --------------------------------------------------------------------------- - // -------------------------------------------------------------------------------------------- // #350: Public policy access - // ------------------------------------------------------------------------------------------- + // --------------------------------------------------------------------------- /** * Returns the current session policy for external consumers. @@ -92,9 +103,9 @@ export class AuthSessionService { return { ...this.sessionPolicy }; } - // -------------------------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // Public API - // -------------------------------------------------------------------------------------------- + // --------------------------------------------------------------------------- /** * Creates a new session for the given user. @@ -108,15 +119,26 @@ export class AuthSessionService { const sessionId = randomUUID(); const now = new Date(); const expiresAt = new Date(now.getTime() + this.sessionPolicy.refreshTokenTtl * 1000); - const { accessToken, refreshToken } = await this.signTokenPair(userId, role, sessionId); - const deviceHash = deviceFingerprint ? this.hashDevice(deviceFingerprint) : undefined; + const { accessToken, refreshToken } = await this.signTokenPair( + userId, + role, + sessionId, + ); + + const deviceHash = deviceFingerprint + ? this.hashDevice(deviceFingerprint) + : undefined; + + // #350: Enforce single-session mode by revoking other sessions if (this.sessionPolicy.singleSessionMode) { await this.revokeAllUserSessions(userId); } + // #350: Enforce max concurrent sessions const activeSessions = await this.getActiveSessions(userId); if (activeSessions.length >= this.sessionPolicy.maxConcurrentSessions) { + // Revoke oldest session const oldest = activeSessions.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), )[0]; @@ -128,12 +150,11 @@ export class AuthSessionService { } } - const session: Session & { lastUsedAt: Date } = { + const session: Session = { sessionId, userId, role, refreshTokenHash: this.hashToken(refreshToken), - refreshTokenThash: this.hashToken(refreshToken), createdAt: now, expiresAt, revoked: false, @@ -141,7 +162,6 @@ export class AuthSessionService { isTrustedDevice: deviceHash ? await this.isTrustedDevice(userId, deviceHash) : undefined, - lastUsedAt: now, }; await this.setSession(session); @@ -151,10 +171,17 @@ export class AuthSessionService { this.logger.warn(`New device login for user ${userId}`); } - await this.auditService.create({ action: 'login', actor: userId, outcome: 'SUCCESS', session: sessionId, requestContext: { deviceHash } }); + this.auditService?.create({ action: 'login', actor: userId, outcome: 'SUCCESS', session: sessionId, requestContext: { deviceHash } }); return this.buildTokensResponse(accessToken, refreshToken); } + /** + * Rotates a refresh token: + * 1. Validates and decodes the incoming refresh JWT. + * 2. Verifies the session exists and is not revoked / expired. + * 3. Revokes the old session record. + * 4. Issues a fresh token pair under a new sessionId. + */ async refreshTokens(rawRefreshToken: string): Promise { let payload: RefreshTokenPayload; try { @@ -169,68 +196,24 @@ export class AuthSessionService { }); } - return this.withRefreshLock(payload.sessionId, async () => { - const claimKey = `refreshClaim:${payload.sessionId}`; - const claimed = await this.redis.set(claimKey, randomUUID(), 'EX', 30, 'NX'); - if (claimed !== 'OK') { - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session has been revoked or does not exist', - }); - } - - const session = await this.getSession(payload.sessionId); - if (!session || session.revoked) { - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session has been revoked or does not exist', - }); - } - - if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'TOKEN_REUSE_DETECTED', - message: 'Refresh token has already been used; session revoked', - }); - } + const session = await this.getSession(payload.sessionId); + if (!session || session.revoked) { + throw new UnauthorizedException({ + error: 'SESSION_NOT_FOUND', + message: 'Session has been revoked or does not exist', + }); + } if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - // A replay indicates that the user's refresh-token family may be compromised. + // Replay detection — the whole refresh-token family may be compromised. await this.revokeAllUserSessions(session.userId, 'token_reuse'); - if (new Date() > new Date(session.expiresAt.getTime() + this.sessionPolicy.deliveryGracePeriod * 1000)) { - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired; please log in again', - }); - } - - // This write is inside the per-session lock, so only one concurrent - // request can observe and consume the valid refresh token. - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - return this.createSession(session.userId, session.role); - }); - if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - // Token reuse detected - revoke the whole session as a security measure. - session.revoked = true; - await this.setSession(session); throw new UnauthorizedException({ error: 'TOKEN_REUSE_DETECTED', message: 'Refresh token has already been used; session revoked', }); } - const now = new Date(); - // Enforce absolute expiry (including delivery grace) before relying on JWT expiry. - if (this.isSessionExpired(session, now)) { + if (new Date() > new Date(session.expiresAt.getTime() + this.sessionPolicy.deliveryGracePeriod * 1000)) { session.revoked = true; await this.setSession(session); throw new UnauthorizedException({ @@ -239,21 +222,11 @@ export class AuthSessionService { }); } - // Enforce idle timeout independently of token validity. - if (this.isSessionIdle(session, now)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_IDLE_TIMEOUT', - message: 'Session has been idle for too long; please log in again', - }); - } - // Revoke the old session before issuing new tokens (rotation). session.revoked = true; await this.setSession(session); - await this.auditService.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); + this.auditService?.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); return await this.createSession(session.userId, session.role); } @@ -262,13 +235,12 @@ export class AuthSessionService { * Also clears any cached refresh-token data associated with the session. */ async revokeSession(sessionId: string, reason = 'logout'): Promise { - async revokeSession(sessionId: string): Promise { const session = await this.getSession(sessionId); if (session) { session.revoked = true; await this.setSession(session); this.logger.log(`Session ${sessionId} revoked for user ${session.userId}`); - this.auditService.create({ action: reason, actor: session.userId, outcome: 'SUCCESS', session: sessionId }); + this.auditService?.create({ action: reason, actor: session.userId, outcome: 'SUCCESS', session: sessionId }); } } @@ -277,12 +249,6 @@ export class AuthSessionService { * Clears all associated refresh tokens and cached session data. */ async revokeAllUserSessions(userId: string, reason = 'logout_all'): Promise { - await this.auditService.create({ action: 'logout', actor: session.userId, outcome: 'SUCCESS', session: sessionId }); - } - } - - async revokeAllUserSessions(userId: string): Promise { - const sessionIds = await this.redis.smembers(this.userSessionsKey(userId)); const sessionIds = await this.redis.smembers(`userSessions:${userId}`); let count = 0; for (const sessionId of sessionIds) { @@ -294,7 +260,7 @@ export class AuthSessionService { } } this.logger.log(`All ${count} sessions revoked for user ${userId}`); - this.auditService.create({ action: reason, actor: userId, outcome: 'SUCCESS', requestContext: { count } }); + this.auditService?.create({ action: reason, actor: userId, outcome: 'SUCCESS', requestContext: { count } }); } async onPasswordChanged(userId: string): Promise { @@ -315,36 +281,27 @@ export class AuthSessionService { await this.redis.del(`trustedDevices:${userId}`); } - async getActiveSessions(userId: string): Promise[]> { - const sessionIds = await this.redis.smembers(this.userSessionsKey(userId)); - await this.auditService.create({ action: 'logout_all', actor: userId, outcome: 'SUCCESS', requestContext: { count } }); - } - /** - * Returns all active (non-revoked, non-expired, not idle) sessions for a user. + * Returns all active (non-revoked, non-expired) sessions for a user. */ async getActiveSessions(userId: string): Promise[]> { - async getActiveSessions(userId: string): Promise[]> { const sessionIds = await this.redis.smembers(`userSessions:${userId}`); const now = new Date(); const result: Omit[] = []; for (const sessionId of sessionIds) { const session = await this.getSession(sessionId); if (session && !session.revoked && session.expiresAt > now) { - result.push(session); - if (session && session.userId === userId && !session.revoked && session.expiresAt > now) { - const { refreshTokenHash: _hash, ...rest } = session; - if (session && !session.revoked && !this.isSessionExpired(session, now) && !this.isSessionIdle(session, now)) { - const { refreshToken, ...rest } = session; + const { refreshTokenHash, ...rest } = session; + void refreshTokenHash; result.push(rest); } } return result; } - // ------------------------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // Device binding & trusted device recognition - // -------------------------------------------------------------------------------------------- + // -------------------------------------------------------------------------- hashDevice(fingerprint: string): string { return createHash('sha256').update(fingerprint).digest('hex'); @@ -357,51 +314,26 @@ export class AuthSessionService { async addTrustedDevice(userId: string, deviceHash: string): Promise { await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ action: 'add_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); - await this.auditService.create({ action: 'add_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); + this.auditService?.create({ action: 'add_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); } async removeTrustedDevice(userId: string, deviceHash: string): Promise { await this.redis.srem(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ action: 'remove_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); + this.auditService?.create({ action: 'remove_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); } async getTrustedDevices(userId: string): Promise { return await this.redis.smembers(`trustedDevices:${userId}`); } - async getTrustedDevices(userId: string): Promise { - return this.redis.smembers(`trustedDevices:${userId}`); - } - async checkDeviceTrust(userId: string, deviceFingerprint: string): Promise<{ trusted: boolean; deviceHash: string }> { const deviceHash = this.hashDevice(deviceFingerprint); return { trusted: await this.isTrustedDevice(userId, deviceHash), deviceHash }; } - private hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); - } - - private async withRefreshLock(sessionId: string, operation: () => Promise): Promise { - const previous = this.refreshLocks.get(sessionId) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - const queued = previous.then(() => current); - this.refreshLocks.set(sessionId, queued); - - await previous; - try { - return await operation(); - } finally { - release(); - if (this.refreshLocks.get(sessionId) === queued) { - this.refreshLocks.delete(sessionId); - } - } - } + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- private sessionKey(sessionId: string): string { return `session:${sessionId}`; @@ -421,37 +353,19 @@ export class AuthSessionService { } private async setSession(session: Session): Promise { - const tll = Math.max(1, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + this.sessionPolicy.deliveryGracePeriod); - await this.redis.set(this.sessionKey(session.sessionId), JSON.stringify(session), tll * 1000); - const userKey = this.userSessionsKey(session.userId); - await this.redis.sadd(userKey, session.sessionId); - const session = JSON.parse(data) as Session; - return { - ...session, - createdAt: new Date(session.createdAt), - expiresAt: new Date(session.expiresAt), - }; - } - - private async setSession(session: Session): Promise { - const ttlSeconds = Math.max( + const ttlMs = Math.max( 1, - Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + this.sessionPolicy.deliveryGracePeriod, + (session.expiresAt.getTime() - Date.now()) + + this.sessionPolicy.deliveryGracePeriod * 1000, ); - await this.redis.set(this.sessionKey(session.sessionId), JSON.stringify(session), 'EX', ttlSeconds); + await this.redis.set(this.sessionKey(session.sessionId), JSON.stringify(session), ttlMs); await this.redis.sadd(this.userSessionsKey(session.userId), session.sessionId); } private get refreshSecret(): string { - return this.configService.get('JWT_REFRESH_SECRET', 'change-me'); - } - await this.auditService.create({ action: 'remove_trusted_device', actor: userId, outcome: 'SUCCESS', requestContext: { deviceHash } }); + return this.configService.get('JWT_REFRESH_SECRET', this.configService.get('JWT_SECRET', 'change-me')); } - // -------------------------------------------------------------------------------------------- - // Private helpers - // -------------------------------------------------------------------------------------------- - private async signTokenPair( userId: string, role: UserRole, @@ -459,32 +373,25 @@ export class AuthSessionService { ): Promise<{ accessToken: string; refreshToken: string }> { const accessPayload: JwtPayload = { sub: userId, role }; const refreshPayload: RefreshTokenPayload = { sub: userId, role, sessionId }; + const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(accessPayload, { expiresIn: this.sessionPolicy.accessTokenTtl, + // Access token uses the default JWT_SECRET set in JwtModule. }), this.jwtService.signAsync(refreshPayload, { secret: this.refreshSecret, expiresIn: this.sessionPolicy.refreshTokenTtl, }), ]); - const accessPayload: JwtPayload = { sub: userId, role, sessionId, type: 'access' }; - const refreshPayload: RefreshTokenPayload = { sub: userId, role, sessionId, type: 'refresh' }; - - const accessToken = await this.jwtService.signAsync(accessPayload, { - secret: this.accessSecret, - expiresIn: this.sessionPolicy.accessTokenTtl, - }); - - const refreshToken = await this.jwtService.signAsync(refreshPayload, { - secret: this.refreshSecret, - expiresIn: this.sessionPolicy.refreshTokenTtl, - }); return { accessToken, refreshToken }; } - private buildTokensResponse(accessToken: string, refreshToken: string): AuthTokensResponse { + private buildTokensResponse( + accessToken: string, + refreshToken: string, + ): AuthTokensResponse { return { accessToken, refreshToken, @@ -492,32 +399,4 @@ export class AuthSessionService { expiresIn: this.sessionPolicy.accessTokenTtl, }; } - return { accessToken, refreshToken }; - } - - private async setSession(session: Session & { lastUsedAt?: Date }): Promise { - const key = `session:${session.sessionId}`; - // Store with TTL long enough to cover expiry +grace+buffer. - const ttlSeconds = this.sessionPolicy.refreshTokenTtl + this.sessionPolicy.deliveryGracePeriod + 10; // +10s buffer offset - await this.redis.set(key, JSON.stringify(session), 'EX', ttlSeconds); - // Add to user's session set if not already there. - await this.redis.sadd(`userSessions:${session.userId}`, session.sessionId); - } - - private async getSession(sessionId: string): Promise<(Session & { lastUsedAt?: Date }) | null> { - const key = `session:${sessionId}`; - const raw = await this.redis.get(key); - if (!raw) return null; - return JSON.parse(raw) as Session & { lastUsedAt?: Date }; - } - - private isSessionExpired(session: Session, now: Date): boolean { - const expiryWithGrace = new Date(new Date(session.expiresAt).getTime() + this.sessionPolicy.deliveryGracePeriod * 1000); - return now > expiryWithGrace; - } - - private isSessionIdle(session: Session & { lastUsedAt?: Date }, now: Date): boolean { - const lastUsedAt = session.lastUsedAt ? new Date(session.lastUsedAt) : new Date(session.createdAt); - return now.getTime() - lastUsedAt.getTime() > this.sessionPolicy.idleSessionTimeout * 1000; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/auth.module.ts b/BackendAcademy/src/auth/auth.module.ts index d4ac0a918..325ca2079 100644 --- a/BackendAcademy/src/auth/auth.module.ts +++ b/BackendAcademy/src/auth/auth.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisModule } from '../redis/redis.module'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtLearnerGuard } from './guards/jwt-learner.guard'; import { JwtTutorGuard } from './guards/jwt-tutor.guard'; import { JwtAdminGuard } from './guards/jwt-admin.guard'; import { RolesGuard } from './guards/roles.guard'; +import { SubjectOwnershipGuard } from './guards/subject-ownership.guard'; import { AuthSessionService } from './auth-session.service'; import { AuthSessionController } from './auth-session.controller'; import { AuditModule } from '../audit/audit.module'; @@ -31,27 +33,27 @@ import { AuditModule } from '../audit/audit.module'; }, }; }, - useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'changeme'), - signOptions: { expiresIn: '15m' }, - }), inject: [ConfigService], }), ], controllers: [AuthSessionController], providers: [ + JwtAuthGuard, JwtLearnerGuard, JwtTutorGuard, JwtAdminGuard, RolesGuard, + SubjectOwnershipGuard, AuthSessionService, ], exports: [ JwtModule, + JwtAuthGuard, JwtLearnerGuard, JwtTutorGuard, JwtAdminGuard, RolesGuard, + SubjectOwnershipGuard, AuthSessionService, ], }) diff --git a/BackendAcademy/src/auth/decorators/ownership.decorator.ts b/BackendAcademy/src/auth/decorators/ownership.decorator.ts new file mode 100644 index 000000000..0f6d89dd3 --- /dev/null +++ b/BackendAcademy/src/auth/decorators/ownership.decorator.ts @@ -0,0 +1,16 @@ +import { SetMetadata } from '@nestjs/common'; + +export const OWNERSHIP_KEY = 'ownershipParams'; + +/** + * Declares which route params carry the authenticated user's own subject id. + * + * When combined with `SubjectOwnershipGuard`, non-admin callers are only + * allowed through when the given `:param` value equals the JWT subject + * (`req.user.sub`). + * + * Usage: + * @Ownership('userId') + */ +export const Ownership = (...params: string[]) => + SetMetadata(OWNERSHIP_KEY, params); \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/guards.spec.ts b/BackendAcademy/src/auth/guards/guards.spec.ts new file mode 100644 index 000000000..9386c7363 --- /dev/null +++ b/BackendAcademy/src/auth/guards/guards.spec.ts @@ -0,0 +1,215 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtAdminGuard } from './jwt-admin.guard'; +import { JwtTutorGuard } from './jwt-tutor.guard'; +import { JwtLearnerGuard } from './jwt-learner.guard'; +import { RolesGuard } from './roles.guard'; +import { SubjectOwnershipGuard } from './subject-ownership.guard'; +import { UserRole } from '../enums/user-role.enum'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +function mockJwtService(verify?: jest.Mock) { + return { + verifyAsync: verify ?? jest.fn(), + } as unknown as JwtService; +} + +function mockContext(req: Record): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; +} + +function payload(sub: string, role: UserRole): JwtPayload { + return { sub, role }; +} + +describe('JwtAuthGuard', () => { + it('rejects requests without a Bearer token', async () => { + const guard = new JwtAuthGuard(mockJwtService()); + await expect( + guard.canActivate(mockContext({ headers: {} })), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects requests with an invalid token', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockRejectedValue(new Error('bad signature'))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer not.a.token' } }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects tokens without a subject', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockResolvedValue({ role: UserRole.LEARNER })), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('attaches the decoded payload to request.user for any role', async () => { + const guard = new JwtAuthGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'u-1' }); + }); +}); + +describe('JwtAdminGuard', () => { + it('forbids non-admin JWTs', async () => { + const guard = new JwtAdminGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('allows admin JWTs and attaches request.user', async () => { + const guard = new JwtAdminGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('a-1', UserRole.ADMIN))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'a-1', role: UserRole.ADMIN }); + }); +}); + +describe('JwtTutorGuard', () => { + it('forbids non-tutor JWTs', async () => { + const guard = new JwtTutorGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('u-1', UserRole.LEARNER))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows tutor JWTs and attaches a standardized request.user', async () => { + const guard = new JwtTutorGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('t-1', UserRole.TUTOR))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 't-1', role: UserRole.TUTOR }); + }); +}); + +describe('JwtLearnerGuard', () => { + it('forbids non-learner JWTs', async () => { + const guard = new JwtLearnerGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('t-1', UserRole.TUTOR))), + ); + await expect( + guard.canActivate( + mockContext({ headers: { authorization: 'Bearer valid' } }), + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows learner JWTs', async () => { + const guard = new JwtLearnerGuard( + mockJwtService(jest.fn().mockResolvedValue(payload('l-1', UserRole.LEARNER))), + ); + const req: any = { headers: { authorization: 'Bearer valid' } }; + await expect(guard.canActivate(mockContext(req))).resolves.toBe(true); + expect(req.user).toMatchObject({ sub: 'l-1', role: UserRole.LEARNER }); + }); +}); + +describe('RolesGuard', () => { + it('allows when no roles are declared', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue(undefined), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('rejects when the user does not hold a declared role', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue([UserRole.ADMIN]), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(() => guard.canActivate(mockContext(req))).toThrow(ForbiddenException); + }); + + it('allows when the user holds a declared role', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest + .fn() + .mockReturnValue([UserRole.LEARNER, UserRole.ADMIN]), + } as any); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('rejects when no authenticated user is present', () => { + const guard = new RolesGuard({ + getAllAndOverride: jest.fn().mockReturnValue([UserRole.LEARNER]), + } as any); + expect(() => guard.canActivate(mockContext({}))).toThrow(ForbiddenException); + }); +}); + +describe('SubjectOwnershipGuard', () => { + const reflector = (params?: string[]) => + ({ getAllAndOverride: jest.fn().mockReturnValue(params) }) as any; + + it('rejects when the user is not authenticated', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + expect(() => + guard.canActivate( + mockContext({ params: { userId: 'u-1' } }), + ), + ).toThrow(ForbiddenException); + }); + + it('rejects when the route param belongs to another subject', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('u-1', UserRole.LEARNER), + params: { userId: 'u-2' }, + }; + expect(() => guard.canActivate(mockContext(req))).toThrow(ForbiddenException); + }); + + it('allows when the route param matches the authenticated subject', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('u-1', UserRole.LEARNER), + params: { userId: 'u-1' }, + }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('allows admins to operate across subjects', () => { + const guard = new SubjectOwnershipGuard(reflector(['userId'])); + const req: any = { + user: payload('a-1', UserRole.ADMIN), + params: { userId: 'u-999' }, + }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); + + it('allows when no ownership params are declared', () => { + const guard = new SubjectOwnershipGuard(reflector(undefined)); + const req: any = { user: payload('u-1', UserRole.LEARNER) }; + expect(guard.canActivate(mockContext(req))).toBe(true); + }); +}); \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts b/BackendAcademy/src/auth/guards/jwt-admin.guard.ts index 2e86de46e..1653cadb4 100644 --- a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-admin.guard.ts @@ -1,90 +1,41 @@ import { - CanActivate, ExecutionContext, - Injectable, - UnauthorizedException, ForbiddenException, + Injectable, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; -import { AuthSessionService } from '../auth-session.service'; /** * Protects routes that require a valid admin JWT. * - * Expects an `authorization: Bearer ` Header. - * The token payload must contain role: "admin". + * Expects an `Authorization: Bearer ` header. + * The token payload must contain `role: "admin"`. * - * On success, attaches request.user with the decoded payload. + * On success, attaches `request.user` with the decoded payload. */ @Injectable() -export class JwtAdminGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, - ) {} +export class JwtAdminGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } - - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); - if (payload.role !== UserRole.ADMIN) { + if (request.user.role !== UserRole.ADMIN) { throw new ForbiddenException({ error: 'ADMIN_ROLE_REQUIRED', message: 'Only admins are allowed to access this resource', }); } - // Enforce session expiration independently of JWT verification. - // This ensures absolute expiry, delivery grace, and idle timeout - // are applied even when the JWT itself is still valid. - const sessionId = - (payload as any).sessionId ?? (payload as any).jti ?? (payload as any).sid; - if (!sessionId) { - throw new UnauthorizedException({ - error: 'MISSING_SESSION', - message: 'Token does not contain a valid session identifier', - }); - } - - try { - await this.authSessionService.validateSession(sessionId); - } catch (err) { - if (err instanceof UnauthorizedException) { - throw err; - } - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired or is inactive', - }); - } - - // Attach decoded user identity for downstream handlers - (request as Request & { user: JwtPayload }).user = payload; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } } \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-auth.guard.ts b/BackendAcademy/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..fb6c8f5f3 --- /dev/null +++ b/BackendAcademy/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,69 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +/** + * Generic authentication guard. + * + * Verifies the `Authorization: Bearer ` header and attaches the + * decoded `JwtPayload` to `request.user` for downstream role guards and + * handlers. Role enforcement is the responsibility of `JwtAdminGuard`, + * `JwtTutorGuard`, `JwtLearnerGuard`, or `RolesGuard`. + */ +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(protected readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + + if (!token) { + throw new UnauthorizedException({ + error: 'MISSING_TOKEN', + message: 'Authorization header with Bearer token is required', + }); + } + + let payload: JwtPayload; + try { + payload = await this.jwtService.verifyAsync(token); + } catch { + throw new UnauthorizedException({ + error: 'INVALID_TOKEN', + message: 'Token is invalid or has expired', + }); + } + + // A valid signature alone is not enough — a subject must be present. + if (!payload || !payload.sub) { + throw new UnauthorizedException({ + error: 'INVALID_TOKEN', + message: 'Token is invalid or has expired', + }); + } + + this.attachUser(request, payload); + return true; + } + + /** + * Attaches the decoded identity to `request.user`. Standardizes the + * subject property across every role guard so ownership guards and + * handlers can always read `request.user`. + */ + protected attachUser(request: Request, payload: JwtPayload): void { + (request as Request & { user: JwtPayload }).user = payload; + } + + protected extractBearerToken(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts index c907caeb4..ce55ccdc3 100644 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts @@ -1,49 +1,41 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException } from '@nestj/common'; -import { SessionService } from '../session.service'; -import { JstService } from '@nestjs/jstt'; +import { + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; -import { JstPayload } from '../interfaces/jstt-payload.interface'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; +/** + * Protects routes that require a valid learner JWT. + * + * Expects an `Authorization: Bearer ` header. + * The token payload must contain `role: "learner"`. + * + * On success, attaches `request.user` with the decoded payload. + */ @Injectable() -export class JwtLearnerGuard implements CanActivate { - constructor( - private readonly jwtService: JstService, - private readonly sessionService: SessionService, - ) {} +export class JwtLearnerGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ error: 'MISSING_TOKEN', message: 'Authorization header with Bearer token is required' }); - } - - let payload: JwtPayload & { sessionId?: string }; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ error: 'INVALID_TOKEN', message: 'Token is invalid or has expired' }); - } - - if (payload.role !== UserRole.LEARNER) { - throw new ForbiddenException({ error: 'LEARNER_ROLE_REQUIRED', message: 'Only learners are allowed to access this resource' }); + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); + + if (request.user.role !== UserRole.LEARNER) { + throw new ForbiddenException({ + error: 'LEARNER_ROLE_REQUIRED', + message: 'Only learners are allowed to access this resource', + }); } - if (!payload.sessionId) { - throw new UnauthorizedException({ error: 'MISSING_SESSION_ID', message: 'Token does not contain session id' }); - } - - // Enforce session expiration independently of JWT verification. - await this.sessionService.validateSession(payload.sessionId); - - (request as Request & { user: JwtPayload }).user = payload; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts index 396ab57c5..bdcb0f233 100644 --- a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts @@ -1,91 +1,45 @@ import { - CanActivate, ExecutionContext, - Injectable, - UnauthorizedException, ForbiddenException, + Injectable, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; +import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; -import { AuthSessionService } from '../auth-session.service'; /** * Protects routes that require a valid tutor JWT. * - * Expects an `Authorization: Bearer ` Header. - * The token payload must contain `role: "tutor"` and a `sessionId`. - * The associated session must not be expired, revoked, or idle. + * Expects an `Authorization: Bearer ` header. + * The token payload must contain `role: "tutor"`. * - * On success, attaches `request.tutor` with the decoded payload. + * On success, attaches `request.user` (and the deprecated `request.tutor` + * alias) with the decoded payload. */ @Injectable() -export class JwtTutorGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, - ) {} +export class JwtTutorGuard extends JwtAuthGuard { + constructor(jwtService: JwtService) { + super(jwtService); + } async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } - - let payload: JwtPayload; - try { - payload = await this&jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } + await super.canActivate(context); + const request = context + .switchToHttp() + .getRequest(); - if (payload.role !== UserRole.TUTIOR) { + if (request.user.role !== UserRole.TUTOR) { throw new ForbiddenException({ - error: 'TUTIOR_ROLE_REQUIRED', + error: 'TUTOR_ROLE_REQUIRED', message: 'Only tutors are allowed to access this resource', }); } - // Enforce session expiration independently of JWT verification. - // The JWT may be valid, but the session could be expired, revoked, - // or idle beyond the allowed timeout. Session validation also - // refreshes the last activity timestamp and removes/marks expired - // sessions as required. - if (!payload.sessionId) { - throw new UnauthorizedException({ - error: 'MISSING_SESSION', - message: 'Token does not contain a session identifier', - }); - } - - try { - await this.authSessionService.validateAndRefreshSession(payload.sessionId); - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - throw new UnauthorizedException({ - error: 'INVALID_SESSION', - message: 'Session is expired, revoked, or inactive', - }); - } - - // Attach decoded tutor identity for downstream handlers - (request as Request & { tutor: JwtPayload }).tutor = payload; + // Backward-compatibility alias for handlers that read `request.tutor`. + (request as Request & { user: JwtPayload; tutor?: JwtPayload }).tutor = + request.user; return true; } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/subject-ownership.guard.ts b/BackendAcademy/src/auth/guards/subject-ownership.guard.ts new file mode 100644 index 000000000..26663aa15 --- /dev/null +++ b/BackendAcademy/src/auth/guards/subject-ownership.guard.ts @@ -0,0 +1,72 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { OWNERSHIP_KEY } from '../decorators/ownership.decorator'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { UserRole } from '../enums/user-role.enum'; + +/** + * Guard that enforces subject ownership on a per-route basis. + * + * Run this guard AFTER a JWT guard has attached `req.user`, and declare the + * route params that identify the target subject with `@Ownership(...)`. + * + * - Admins are allowed to operate across subjects. + * - Non-admins must have `req.user.sub` equal to every declared param value, + * otherwise the request is rejected with 403 SUBJECT_MISMATCH. + * + * Usage: + * @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + * @Roles(UserRole.LEARNER, UserRole.ADMIN) + * @Ownership('userId') + * async method() { ... } + */ +@Injectable() +export class SubjectOwnershipGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + // Admins manage every subject. + if (user.role === UserRole.ADMIN) { + return true; + } + + const ownedParams = this.reflector.getAllAndOverride( + OWNERSHIP_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!ownedParams || ownedParams.length === 0) { + return true; + } + + for (const param of ownedParams) { + const claimedSubject = request.params?.[param]; + if (claimedSubject !== undefined && claimedSubject !== user.sub) { + throw new ForbiddenException({ + error: 'SUBJECT_MISMATCH', + message: 'You do not have access to another user\'s resource', + }); + } + } + + return true; + } +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/helpers/subject.helper.ts b/BackendAcademy/src/auth/helpers/subject.helper.ts new file mode 100644 index 000000000..a4bc35781 --- /dev/null +++ b/BackendAcademy/src/auth/helpers/subject.helper.ts @@ -0,0 +1,58 @@ +import { ForbiddenException } from '@nestjs/common'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { UserRole } from '../enums/user-role.enum'; + +/** + * Verifies that an authenticated user owns a resource whose subject id is + * resolved separately (e.g. from a loaded entity). + * + * Admins are allowed to operate across subjects; everyone else must match + * the JWT subject exactly. Throws 403 SUBJECT_MISMATCH otherwise. + */ +export function assertSameSubject( + user: JwtPayload | undefined, + ownerId: string | null | undefined, + resource = 'resource', +): void { + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + if (user.role === UserRole.ADMIN) { + return; + } + + if (!ownerId || ownerId !== user.sub) { + throw new ForbiddenException({ + error: 'SUBJECT_MISMATCH', + message: `You do not have access to this ${resource}`, + }); + } +} + +/** + * Allowed for the owning user unless the caller holds one of `staffRoles` + * (e.g. tutor or admin). Non-staff callers must own the resource. + */ +export function assertOwnerOrStaff( + user: JwtPayload | undefined, + ownerId: string | null | undefined, + staffRoles: UserRole[] = [UserRole.TUTOR, UserRole.ADMIN], + resource = 'resource', +): void { + if (!user) { + throw new ForbiddenException({ + error: 'FORBIDDEN', + message: 'Authentication is required before ownership can be verified', + }); + } + + if (staffRoles.includes(user.role)) { + return; + } + + assertSameSubject(user, ownerId, resource); +} \ No newline at end of file diff --git a/BackendAcademy/src/auth/index.ts b/BackendAcademy/src/auth/index.ts index 7037017e4..890fafbdc 100644 --- a/BackendAcademy/src/auth/index.ts +++ b/BackendAcademy/src/auth/index.ts @@ -1,9 +1,13 @@ export { AuthModule } from './auth.module'; +export { JwtAuthGuard } from './guards/jwt-auth.guard'; export { JwtLearnerGuard } from './guards/jwt-learner.guard'; export { JwtTutorGuard } from './guards/jwt-tutor.guard'; export { JwtAdminGuard } from './guards/jwt-admin.guard'; export { RolesGuard } from './guards/roles.guard'; +export { SubjectOwnershipGuard } from './guards/subject-ownership.guard'; export { Roles, ROLES_KEY } from './decorators/roles.decorator'; +export { Ownership, OWNERSHIP_KEY } from './decorators/ownership.decorator'; +export { assertSameSubject, assertOwnerOrStaff } from './helpers/subject.helper'; export { UserRole } from './enums/user-role.enum'; export { JwtPayload } from './interfaces/jwt-payload.interface'; export { AuthSessionService } from './auth-session.service'; diff --git a/BackendAcademy/src/auth/interfaces/session.interface.ts b/BackendAcademy/src/auth/interfaces/session.interface.ts index 8e9d0b236..2f179fd37 100644 --- a/BackendAcademy/src/auth/interfaces/session.interface.ts +++ b/BackendAcademy/src/auth/interfaces/session.interface.ts @@ -26,13 +26,13 @@ export interface Session { expiresAt: Date; /** Absolute maximum lifetime of the session, independent of JWT exp. */ - absoluteExpiresAt: Date; + absoluteExpiresAt?: Date; /** Timestamp after which the session is considered idle-expired if no activity. */ - idleExpiresAt: Date; + idleExpiresAt?: Date; /** Grace period in seconds allowed for token delivery after expiry (clock skew buffer). */ - deliveryGraceSeconds: number; + deliveryGraceSeconds?: number; /** Flag set to true once the session is revoked (logout / rotation). */ revoked: boolean; @@ -46,3 +46,25 @@ export interface Session { /** Whether the device has been previously trusted by this user. */ isTrustedDevice?: boolean; } + +/** + * Payload embedded in a signed refresh JWT. + */ +export interface RefreshTokenPayload { + sub: string; + sessionId: string; + role: UserRole; + iat?: number; + exp?: number; +} + +/** + * Returned to the caller after a successful login or token refresh. + */ +export interface AuthTokensResponse { + accessToken: string; + refreshToken: string; + tokenType: 'Bearer'; + /** Access token TTL in seconds. */ + expiresIn: number; +} diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts index 4b64d9427..508d2661a 100644 --- a/BackendAcademy/src/chat/chat.service.ts +++ b/BackendAcademy/src/chat/chat.service.ts @@ -104,7 +104,10 @@ export class ChatService { (m) => (m as any).streamingComplete !== false, ); return before - this.messages.length; - * Run outgoing chat content through the SecurityService prompt sanitiser + } + + /** + * #371: Run outgoing chat content through the SecurityService prompt sanitiser * when the content is long enough that it might plausibly be AI-bound or * carry instructions. The original behaviour is preserved when no * SecurityService is wired in (e.g. unit tests). diff --git a/BackendAcademy/src/common/response.interceptor.ts b/BackendAcademy/src/common/response.interceptor.ts index b0d05ad6d..6a4153d7f 100644 --- a/BackendAcademy/src/common/response.interceptor.ts +++ b/BackendAcademy/src/common/response.interceptor.ts @@ -1,4 +1,4 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs-common'; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -8,7 +8,8 @@ export interface ResponseEnvelope { data: T; } -@Injectable()\nexport class ResponseInterceptor implements NestInterceptor> { +@Injectable() +export class ResponseInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { return next.handle().pipe( map((rawData) => { diff --git a/BackendAcademy/src/config/config.module.ts b/BackendAcademy/src/config/config.module.ts index 8262cd612..5c570416c 100644 --- a/BackendAcademy/src/config/config.module.ts +++ b/BackendAcademy/src/config/config.module.ts @@ -82,22 +82,6 @@ export function validateEnvironment( imports: [ NestConfigModule.forRoot({ isGlobal: true, - validationSchema: Joi.object({ - NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), - PORT: Joi.number().default(3000), - DATABASE_URL: Joi.string().optional(), - REDIS_HOST: Joi.string().default('localhost'), - REDIS_PORT: Joi.number().default(6379), - JWT_SECRET: Joi.string().optional(), - /** - * Maximum allowed clock skew (in seconds) tolerated when verifying - * token `exp`/`nbf` claims. Distributed clocks can drift, so a small - * bounded tolerance prevents premature expiry or rejection of tokens - * issued by a peer whose clock is slightly ahead/behind. Bounded here - * to a hard maximum so the window cannot be widened inadvertently. - */ - JWT_CLOCK_SKEW_SECONDS: Joi.number().integer().min(0).max(120).default(30), - }), cache: true, envFilePath: ['.env.local', '.env'], expandVariables: true, diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 8184dde18..4fc6d5373 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -22,8 +22,13 @@ import { RestoreRevisionDto } from './dto/restore-revision.dto'; import { CompleteCourseDto } from './dto/complete-course.dto'; import { CreateRatingDto } from './dto/create-rating.dto'; import { CourseRatingStatsDto } from './dto/rating-stats.dto'; -import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; -import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { + JwtLearnerGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, +} from '../auth'; @Controller('courses') export class CourseController { @@ -136,7 +141,8 @@ export class CourseController { * Returns 201 Created on first submission, 200 OK on update. */ @Post(':id/ratings') - @UseGuards(JwtLearnerGuard) + @UseGuards(JwtLearnerGuard, RolesGuard) + @Roles(UserRole.LEARNER) async submitRating( @Param('id') courseId: string, @Body() dto: CreateRatingDto, @@ -179,7 +185,8 @@ export class CourseController { */ @Delete(':id/ratings') @HttpCode(204) - @UseGuards(JwtLearnerGuard) + @UseGuards(JwtLearnerGuard, RolesGuard) + @Roles(UserRole.LEARNER) async deleteRating( @Param('id') courseId: string, @Request() req: Express.Request & { user: JwtPayload }, diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 3387b872c..8f2bef8df 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -12,6 +12,7 @@ import { TransactionManagerService } from '../common/transaction-manager.service import { ConfigModule } from '@nestjs/config'; import { SearchModule } from '../search/search.module'; import { RedisModule } from '../redis/redis.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { RedisModule } from '../redis/redis.module'; RewardsModule, SearchModule, RedisModule, + AuthModule, ], controllers: [CourseController], providers: [ diff --git a/BackendAcademy/src/courses/progress/progress.controller.ts b/BackendAcademy/src/courses/progress/progress.controller.ts index e0acb7c38..a903680fe 100644 --- a/BackendAcademy/src/courses/progress/progress.controller.ts +++ b/BackendAcademy/src/courses/progress/progress.controller.ts @@ -10,7 +10,16 @@ import { ParseUUIDPipe, Post, Put, + UseGuards, } from '@nestjs/common'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, +} from '../../auth'; import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; import { RecordLessonCompletionDto, @@ -23,6 +32,9 @@ import { import { CourseProgressRecord, ProgressService } from './progress.service'; @Controller('courses/progress') +@UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) +@Roles(UserRole.LEARNER, UserRole.ADMIN) +@Ownership('userId') export class ProgressController { constructor(private readonly progressService: ProgressService) {} diff --git a/BackendAcademy/src/courses/progress/progress.module.ts b/BackendAcademy/src/courses/progress/progress.module.ts index 99c32ad92..839e5d949 100644 --- a/BackendAcademy/src/courses/progress/progress.module.ts +++ b/BackendAcademy/src/courses/progress/progress.module.ts @@ -3,9 +3,10 @@ import { CourseModule } from '../course.module'; import { ProgressController } from './progress.controller'; import { ProgressService } from './progress.service'; import { TransactionManagerService } from '../../common/transaction-manager.service'; +import { AuthModule } from '../../auth/auth.module'; @Module({ - imports: [CourseModule], + imports: [CourseModule, AuthModule], controllers: [ProgressController], providers: [ProgressService, TransactionManagerService], exports: [ProgressService], diff --git a/BackendAcademy/src/payments/payments.service.ts b/BackendAcademy/src/payments/payments.service.ts index 87452880f..8cba5cd51 100644 --- a/BackendAcademy/src/payments/payments.service.ts +++ b/BackendAcademy/src/payments/payments.service.ts @@ -122,7 +122,7 @@ export class PaymentsService { private readonly databaseService: DatabaseService, @Optional() private readonly contractAdapter?: IContractAdapter, - ) {} + @Optional() private readonly configService?: ConfigService, ) { this.defaultTimeoutMs = this.configService?.get('DEFAULT_REQUEST_TIMEOUT_MS') ?? 30_000; @@ -261,14 +261,6 @@ export class PaymentsService { async getAllCoupons() { return this.databaseService.getAllCoupons(); } -} async getRedemptionHistory(userId: string) { - return this.databaseService.getRedemptionsByUser(userId); - } - - async getAllCoupons() { - return this.databaseService.getAllCoupons(); - } -} /** * Processes a validated, signature-checked payment webhook event. diff --git a/BackendAcademy/src/redis/redis.module.ts b/BackendAcademy/src/redis/redis.module.ts index c34bc96dc..20f3b4fe6 100644 --- a/BackendAcademy/src/redis/redis.module.ts +++ b/BackendAcademy/src/redis/redis.module.ts @@ -2,44 +2,6 @@ import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedisService } from './redis.service'; -@Global() -@Module({ - imports: [ConfigModule], - providers: [ - { - provide: 'REDIS_OPTIONS', - useFactory: (config: ConfigService) => ({ - host: config.get('REDIS_HOST', 'localhost'), - port: config.get('REDIS_PORT', 6379), - password: config.get('REDIS_PASSWORD'), - }), - inject: [ConfigService], - }, - RedisService, - { - provide: 'SessionStore', - inject: [RedisService], - useFactory: (redis: RedisService) => ({ - get: async (sessionId: string) => { - const raw = await redis.get(`session:${sessionId}`); - return raw ? JSON.parse(raw) : null; - }, - set: async (sessionId: string, data: any, ttlSeconds: number) => { - await redis.set(`session:${sessionId}`, JSON.stringify(data), 'EX', ttlSeconds); - }, - delete: async (sessionId: string) => { - await redis.del(`session:${sessionId}`); - }, - }), - }, - ], - exports: ['REDIS_OPTIONS', RedisService, 'SessionStore'], -}) -export class RedisModule {} -import { Module, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { RedisService } from './redis.service'; - function parsePort(value: string | undefined, defaultValue: number): number { const raw = value ?? defaultValue.toString(); const port = Number(raw); @@ -66,4 +28,4 @@ function parsePort(value: string | undefined, defaultValue: number): number { ], exports: ['REDIS_OPTIONS', RedisService], }) -export class RedisModule {} +export class RedisModule {} \ No newline at end of file diff --git a/BackendAcademy/src/rewards/rewards.service.ts b/BackendAcademy/src/rewards/rewards.service.ts index bfa9c0bf4..1b93794a6 100644 --- a/BackendAcademy/src/rewards/rewards.service.ts +++ b/BackendAcademy/src/rewards/rewards.service.ts @@ -355,6 +355,7 @@ export class RewardsService { if (this.monitoringService) { this.monitoringService.recordDomainEvent('prize_distributed', 'rewards'); + this.monitoringService.recordDomainEvent('reward_redemptions_total', 'rewards'); } return { diff --git a/BackendAcademy/src/security/authorization.spec.ts b/BackendAcademy/src/security/authorization.spec.ts new file mode 100644 index 000000000..d73ab1f8c --- /dev/null +++ b/BackendAcademy/src/security/authorization.spec.ts @@ -0,0 +1,554 @@ +import { INestApplication, Controller, Get, Post, Put, Param, UseGuards } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { Reflector } from '@nestjs/core'; +import { AddressInfo } from 'net'; + +import { ProgressController } from '../courses/progress/progress.controller'; +import { ProgressService } from '../courses/progress/progress.service'; +import { UserProfileController } from '../users/user-profile.controller'; +import { UserProfileService } from '../users/user-profile.service'; +import { TutorProfileController } from '../users/tutor-profile.controller'; +import { TutorProfileService } from '../users/tutor-profile.service'; +import { SubmissionController } from '../submissions/submission.controller'; +import { SubmissionService } from '../submissions/submission.service'; +import { GradingResultService } from '../submissions/grading-result.service'; +import { TutorReviewController } from '../submissions/tutor-review.controller'; +import { TutorReviewService } from '../submissions/tutor-review.service'; + +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { JwtAdminGuard } from '../auth/guards/jwt-admin.guard'; +import { JwtTutorGuard } from '../auth/guards/jwt-tutor.guard'; +import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { SubjectOwnershipGuard } from '../auth/guards/subject-ownership.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Ownership } from '../auth/decorators/ownership.decorator'; +import { UserRole } from '../auth/enums/user-role.enum'; + +const L1 = '10000000-0000-4000-8000-000000000001'; +const L2 = '10000000-0000-4000-8000-000000000002'; +const T1 = '20000000-0000-4000-8000-000000000001'; +const T2 = '20000000-0000-4000-8000-000000000002'; +const A1 = '30000000-0000-4000-8000-000000000001'; +const PROF_1 = '40000000-0000-4000-8000-000000000001'; +const PROF_2 = '40000000-0000-4000-8000-000000000002'; +const SUB_L1 = '50000000-0000-4000-8000-000000000001'; +const SUB_L2 = '50000000-0000-4000-8000-000000000002'; + +/** + * Probe routers that replicate the exact guard wiring applied by BA-018 to + * routes whose real controllers depend on the half-migrated admin/users + * service layer (admin.service / reports.service do not compile at HEAD). + * The guards, decorators, and role/ownership metadata under test are the + * real production classes. + */ + +@Controller('probe/admin') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +class ProbeAdminController { + @Get('summary') + summary() { + return { ok: true }; + } +} + +@Controller('probe/users') +@UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) +@Roles(UserRole.LEARNER, UserRole.ADMIN) +@Ownership('userId') +class ProbeUsersController { + @Put(':userId/preferences') + updatePreferences(@Param('userId') userId: string) { + return { userId, learnerPreferences: {}, tutorPreferences: {} }; + } +} + +describe('Authorization (roles + subject ownership)', () => { + let app: INestApplication; + let port: number; + let jwt: JwtService; + + const progressService = { + getSnapshot: jest.fn(), + getCourseSnapshot: jest.fn(), + registerCourse: jest.fn(), + recordLessonCompletion: jest.fn(), + recordTaskCompletion: jest.fn(), + resetLearner: jest.fn(), + } as unknown as ProgressService; + + const userProfileService = { + create: jest.fn(), + findAll: jest.fn(), + findByUserId: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + } as unknown as UserProfileService; + + const tutorProfileService = { + create: jest.fn(), + findById: jest.fn(), + findPending: jest.fn(), + update: jest.fn(), + getEarningsSummary: jest.fn(), + rate: jest.fn(), + getReviews: jest.fn(), + } as unknown as TutorProfileService; + + const submissionService = { + findById: jest.fn(), + findAll: jest.fn(), + findByTaskId: jest.fn(), + findByUserId: jest.fn(), + findDraftsByUserId: jest.fn(), + findByStatus: jest.fn(), + create: jest.fn(), + update: jest.fn(), + review: jest.fn(), + remove: jest.fn(), + saveDraft: jest.fn(), + publishDraft: jest.fn(), + } as unknown as SubmissionService; + + const gradingResultService = { + saveResult: jest.fn(), + getResultsBySubmission: jest.fn(), + getLatestResult: jest.fn(), + getResultById: jest.fn(), + deleteResult: jest.fn(), + } as unknown as GradingResultService; + + const tutorReviewService = { + getReviewedByTutor: jest.fn(), + reviewSubmission: jest.fn(), + } as unknown as TutorReviewService; + + /** + * jest.config re-enables mock implementations on every test + * (clearMocks/resetMocks/restoreMocks, #451), so each mock's fixtures + * must be re-installed in a beforeEach. + */ + function reinstallMocks(): void { + (progressService.getSnapshot as jest.Mock).mockImplementation(async (userId: string) => ({ + userId, + generatedAt: new Date(), + overall: {}, + courses: [], + })); + (progressService.getCourseSnapshot as jest.Mock).mockResolvedValue(null); + (progressService.registerCourse as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.recordLessonCompletion as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.recordTaskCompletion as jest.Mock).mockImplementation(async (userId: string) => ({ userId })); + (progressService.resetLearner as jest.Mock).mockResolvedValue(true); + + (userProfileService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: PROF_2, ...dto })); + (userProfileService.findAll as jest.Mock).mockResolvedValue([]); + (userProfileService.findByUserId as jest.Mock).mockResolvedValue(null); + (userProfileService.findById as jest.Mock).mockImplementation(async (id: string) => + id === PROF_1 ? { id: PROF_1, userId: L1 } : null, + ); + (userProfileService.update as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (userProfileService.remove as jest.Mock).mockResolvedValue(true); + + (tutorProfileService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: PROF_1, ...dto })); + (tutorProfileService.findById as jest.Mock).mockImplementation(async (id: string) => + id === PROF_1 ? { id: PROF_1, userId: T1, bio: 'hello tutor' } : null, + ); + (tutorProfileService.findPending as jest.Mock).mockResolvedValue([]); + (tutorProfileService.update as jest.Mock).mockImplementation(async (id: string) => ({ id })); + (tutorProfileService.getEarningsSummary as jest.Mock).mockImplementation(async (id: string) => ({ + tutorId: id, + earnedXlm: 0, + totalPaidOut: 0, + pendingPayouts: 0, + payouts: [], + })); + (tutorProfileService.rate as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (tutorProfileService.getReviews as jest.Mock).mockResolvedValue([]); + + (submissionService.findById as jest.Mock).mockImplementation(async (id: string) => ({ + id, + userId: id === SUB_L2 ? L2 : L1, + content: 'x', + })); + (submissionService.findAll as jest.Mock).mockResolvedValue([]); + (submissionService.findByTaskId as jest.Mock).mockResolvedValue([]); + (submissionService.findByUserId as jest.Mock).mockImplementation(async (userId: string) => + userId === L1 ? [{ id: SUB_L1, userId: L1 }] : [], + ); + (submissionService.findDraftsByUserId as jest.Mock).mockResolvedValue([]); + (submissionService.findByStatus as jest.Mock).mockResolvedValue([]); + (submissionService.create as jest.Mock).mockImplementation(async (dto: any) => ({ id: SUB_L1, ...dto })); + (submissionService.update as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, ...dto })); + (submissionService.review as jest.Mock).mockImplementation( + async (id: string, reviewerId: string, status: string, feedback?: string, score?: number) => ({ id, reviewedBy: reviewerId }), + ); + (submissionService.remove as jest.Mock).mockResolvedValue(true); + (submissionService.saveDraft as jest.Mock).mockImplementation(async (dto: any) => ({ id: SUB_L1, ...dto })); + (submissionService.publishDraft as jest.Mock).mockImplementation(async (id: string) => ({ id })); + + (gradingResultService.saveResult as jest.Mock).mockImplementation(async (id: string, dto: any) => ({ id, submissionId: id, ...dto })); + (gradingResultService.getResultsBySubmission as jest.Mock).mockResolvedValue([]); + (gradingResultService.getLatestResult as jest.Mock).mockResolvedValue(null); + (gradingResultService.getResultById as jest.Mock).mockResolvedValue({}); + (gradingResultService.deleteResult as jest.Mock).mockResolvedValue(undefined); + + (tutorReviewService.getReviewedByTutor as jest.Mock).mockImplementation(async (tutorId: string) => ({ + items: [], + total: 0, + nextCursor: null, + })); + (tutorReviewService.reviewSubmission as jest.Mock).mockImplementation(async (id: string, tutorId: string) => ({ id, reviewedBy: tutorId })); + } + + beforeEach(reinstallMocks); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + JwtModule.register({ secret: 'test-secret', signOptions: { expiresIn: '5m' } }), + ], + controllers: [ + ProbeAdminController, + ProbeUsersController, + ProgressController, + UserProfileController, + TutorProfileController, + SubmissionController, + TutorReviewController, + ], + providers: [ + { provide: ProgressService, useValue: progressService }, + { provide: UserProfileService, useValue: userProfileService }, + { provide: TutorProfileService, useValue: tutorProfileService }, + { provide: SubmissionService, useValue: submissionService }, + { provide: GradingResultService, useValue: gradingResultService }, + { provide: TutorReviewService, useValue: tutorReviewService }, + { provide: Reflector, useValue: new Reflector() }, + JwtAuthGuard, + JwtAdminGuard, + JwtTutorGuard, + JwtLearnerGuard, + RolesGuard, + SubjectOwnershipGuard, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + const server = await app.listen(0); + port = (server.address() as AddressInfo).port; + jwt = moduleRef.get(JwtService); + }); + + afterAll(async () => { + await app.close(); + }); + + async function signToken(sub: string, role: UserRole): Promise { + return jwt.sign({ sub, role }); + } + + async function request( + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + opts: { token?: string; body?: unknown } = {}, + ) { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + const text = await res.text(); + let body: unknown = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: res.status, body }; + } + + describe('Admin route (role-gated)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('GET', '/probe/admin/summary'); + expect(status).toBe(401); + }); + + it('rejects non-admin roles', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', '/probe/admin/summary', { token: learner }); + expect(status).toBe(403); + }); + + it('allows admins', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('GET', '/probe/admin/summary', { token: admin }); + expect(status).toBe(200); + }); + }); + + describe('Own preferences route (role + subject ownership)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('PUT', `/probe/users/${L1}/preferences`); + expect(status).toBe(401); + }); + + it('rejects tutors on a learner-only route', async () => { + const tutor = await signToken(T1, UserRole.TUTOR); + const { status } = await request('PUT', `/probe/users/${T1}/preferences`, { token: tutor }); + expect(status).toBe(403); + }); + + it('allows users to update their own preferences', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: learner, + body: { learnerPreferences: { theme: 'dark' } }, + }); + expect(status).toBe(200); + }); + + it('blocks cross-user preference updates', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: learner2, + body: { learnerPreferences: { theme: 'dark' } }, + }); + expect(status).toBe(403); + }); + + it('lets admins update any user preferences', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('PUT', `/probe/users/${L1}/preferences`, { + token: admin, + body: { tutorPreferences: { timezone: 'UTC' } }, + }); + expect(status).toBe(200); + }); + }); + + describe('ProgressController (subject-owned learner data)', () => { + it('rejects unauthenticated requests', async () => { + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`); + expect(status).toBe(401); + }); + + it('rejects roles outside learner/admin', async () => { + const tutor = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', `/courses/progress/snapshot/${T1}`, { token: tutor }); + expect(status).toBe(403); + }); + + it('allows a learner to read their own snapshot', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: learner }); + expect(status).toBe(200); + expect(progressService.getSnapshot).toHaveBeenCalledWith(L1); + }); + + it('blocks cross-user access', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status, body } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: learner2 }); + expect(status).toBe(403); + expect(body).toMatchObject({ error: 'SUBJECT_MISMATCH' }); + }); + + it('lets admins read any snapshot', async () => { + const admin = await signToken(A1, UserRole.ADMIN); + const { status } = await request('GET', `/courses/progress/snapshot/${L1}`, { token: admin }); + expect(status).toBe(200); + }); + }); + + describe('UserProfileController (own profile)', () => { + it('binds the profile to the authenticated subject on create', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', '/user-profiles', { + token: learner, + body: { userId: L2, displayName: 'spoofed' }, + }); + expect(status).toBe(201); + expect(userProfileService.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: L1 }), + ); + }); + + it('rejects cross-user profile updates', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/user-profiles/${PROF_1}`, { + token: learner2, + body: { displayName: 'hijacked' }, + }); + expect(status).toBe(403); + }); + + it('allows the owner to update their profile', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/user-profiles/${PROF_1}`, { + token: learner, + body: { displayName: 'me' }, + }); + expect(status).toBe(200); + }); + }); + + describe('TutorProfileController (own profile + admin-only listing)', () => { + it('rejects unauthenticated earnings access', async () => { + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`); + expect(status).toBe(401); + }); + + it('blocks a tutor from viewing another tutor earnings', async () => { + const tutor2 = await signToken(T2, UserRole.TUTOR); + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`, { token: tutor2 }); + expect(status).toBe(403); + }); + + it('allows the owning tutor to view their earnings', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', `/tutors/${PROF_1}/earnings`, { token: tutor1 }); + expect(status).toBe(200); + }); + + it('restricts the pending-verification listing to admins', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const tutor = await signToken(T1, UserRole.TUTOR); + expect((await request('GET', '/tutors/pending', { token: learner })).status).toBe(403); + expect((await request('GET', '/tutors/pending', { token: tutor })).status).toBe(403); + const admin = await signToken(A1, UserRole.ADMIN); + expect((await request('GET', '/tutors/pending', { token: admin })).status).toBe(200); + }); + + it('prevents a tutor from rating their own profile', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status, body } = await request('POST', `/tutors/${PROF_1}/rate`, { + token: tutor1, + body: { rating: 5 }, + }); + expect(status).toBe(403); + expect(body).toMatchObject({ error: 'SELF_RATE_FORBIDDEN' }); + }); + + it('binds the rater subject to the JWT when rating another tutor', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', `/tutors/${PROF_1}/rate`, { + token: learner, + body: { raterUserId: T2, rating: 5 }, + }); + expect(status).toBe(201); + expect(tutorProfileService.rate).toHaveBeenCalledWith( + PROF_1, + expect.objectContaining({ raterUserId: L1 }), + ); + }); + }); + + describe('TutorReviewController (tutor role declared)', () => { + it('rejects learners', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', '/tutor/review/history', { token: learner }); + expect(status).toBe(403); + }); + + it('scopes review history to the calling tutor', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('GET', '/tutor/review/history', { token: tutor1 }); + expect(status).toBe(200); + expect(tutorReviewService.getReviewedByTutor).toHaveBeenCalledWith(T1, expect.anything()); + }); + }); + + describe('SubmissionController (learner subject ownership)', () => { + it('forces the author user id from the JWT on create', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('POST', '/submissions', { + token: learner1, + body: { taskId: 'task-1', userId: L2, content: 'answer' }, + }); + expect(status).toBe(201); + expect(submissionService.create).toHaveBeenCalledWith( + expect.objectContaining({ userId: L1 }), + ); + }); + + it('rejects unauthenticated submissions', async () => { + const { status } = await request('POST', '/submissions', { + body: { taskId: 'task-1', userId: L1, content: 'answer' }, + }); + expect(status).toBe(401); + }); + + it('allows a learner to list their own submissions', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('GET', `/submissions/user/${L1}`, { token: learner1 }); + expect(status).toBe(200); + }); + + it('blocks cross-user submission listing', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('GET', `/submissions/user/${L1}`, { token: learner2 }); + expect(status).toBe(403); + }); + + it('restricts the global listing to tutors/admins', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + const tutor = await signToken(T1, UserRole.TUTOR); + expect((await request('GET', '/submissions', { token: learner })).status).toBe(403); + expect((await request('GET', '/submissions', { token: tutor })).status).toBe(200); + }); + + it('blocks cross-user submission updates', async () => { + const learner1 = await signToken(L1, UserRole.LEARNER); + const { status } = await request('PUT', `/submissions/${SUB_L2}`, { + token: learner1, + body: { content: 'overwritten' }, + }); + expect(status).toBe(403); + }); + + it('allows the owning learner to update their submission', async () => { + const learner2 = await signToken(L2, UserRole.LEARNER); + const { status } = await request('PUT', `/submissions/${SUB_L2}`, { + token: learner2, + body: { content: 'fixed' }, + }); + expect(status).toBe(200); + }); + + it('restricts review to tutors/admins and binds the reviewer id', async () => { + const learner = await signToken(L1, UserRole.LEARNER); + expect((await request('POST', `/submissions/${SUB_L1}/review`, { + token: learner, + body: { status: 'approved' }, + })).status).toBe(403); + + const tutor1 = await signToken(T1, UserRole.TUTOR); +const { status } = await request('POST', `/submissions/${SUB_L1}/review`, { + token: tutor1, + body: { status: 'approved', feedback: 'nice' }, + }); + expect(status).toBe(201); + expect(submissionService.review).toHaveBeenCalledWith(SUB_L1, T1, 'approved', 'nice', undefined); + }); + + it('binds the grader id to the JWT subject for grading', async () => { + const tutor1 = await signToken(T1, UserRole.TUTOR); + const { status } = await request('POST', `/submissions/${SUB_L1}/grade`, { + token: tutor1, + body: { graderId: 'someone-else', status: 'pass', score: 10, maxScore: 10, feedback: 'ok' }, + }); + expect(status).toBe(201); + expect(gradingResultService.saveResult).toHaveBeenCalledWith( + SUB_L1, + expect.objectContaining({ graderId: T1 }), + ); + }); + }); +}); \ No newline at end of file diff --git a/BackendAcademy/src/submissions/submission.controller.ts b/BackendAcademy/src/submissions/submission.controller.ts index f70ae6897..b715578b9 100644 --- a/BackendAcademy/src/submissions/submission.controller.ts +++ b/BackendAcademy/src/submissions/submission.controller.ts @@ -9,7 +9,11 @@ import { ParseUUIDPipe, HttpCode, HttpStatus, + UseGuards, + Req, + NotFoundException, } from '@nestjs/common'; +import { Request } from 'express'; import { SubmissionService } from './submission.service'; import { GradingResultService } from './grading-result.service'; import { CreateSubmissionDto } from './dto/create-submission.dto'; @@ -17,7 +21,26 @@ import { UpdateSubmissionDto } from './dto/update-submission.dto'; import { SaveDraftDto } from './dto/save-draft.dto'; import { SaveGradingResultDto } from './dto/save-grading-result.dto'; import { SubmissionStatus } from './interfaces/submission-status.enum'; +import { SubmissionEntity } from './submission.entity'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, + JwtPayload, + assertOwnerOrStaff, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * Submission API. + * + * Learners manage their own submissions (subject ownership verified against + * the JWT subject); tutors and admins operate the review/grading surface. + */ @Controller('submissions') export class SubmissionController { constructor( @@ -30,61 +53,99 @@ export class SubmissionController { // --------------------------------------------------------------------------- @Post() - async create(@Body() dto: CreateSubmissionDto) { - return this.submissionService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + async create(@Body() dto: CreateSubmissionDto, @Req() req: AuthedRequest) { + // The authoring learner always comes from the JWT, never the payload. + return this.submissionService.create({ ...dto, userId: req.user.sub }); } @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findAll() { return this.submissionService.findAll(); } @Get('task/:taskId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findByTaskId(@Param('taskId') taskId: string) { return this.submissionService.findByTaskId(taskId); } @Get('user/:userId') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + @Ownership('userId') async findByUserId(@Param('userId') userId: string) { return this.submissionService.findByUserId(userId); } @Get('user/:userId/drafts') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + @Ownership('userId') async findDraftsByUserId(@Param('userId') userId: string) { return this.submissionService.findDraftsByUserId(userId); } @Get('status/:status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async findByStatus(@Param('status') status: SubmissionStatus) { return this.submissionService.findByStatus(status); } @Get(':id') - async findById(@Param('id', ParseUUIDPipe) id: string) { - return this.submissionService.findById(id); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async findById(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); + return submission; } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSubmissionDto, + @Req() req: AuthedRequest, ) { - return this.submissionService.update(id, dto); + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); + // Ownership fields are immutable through this generic update path. + return this.submissionService.update(id, { + ...dto, + userId: submission.userId, + reviewedBy: submission.reviewedBy, + } as UpdateSubmissionDto); } @Post(':id/review') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async review( @Param('id', ParseUUIDPipe) id: string, - @Body('reviewedBy') reviewerId: string, + @Req() req: AuthedRequest, @Body('status') status: SubmissionStatus, @Body('feedback') feedback?: string, @Body('score') score?: number, ) { - return this.submissionService.review(id, reviewerId, status, feedback, score); + return this.submissionService.review(id, req.user.sub, status, feedback, score); } @Delete(':id') - async remove(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async remove(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); return this.submissionService.remove(id); } @@ -100,8 +161,10 @@ export class SubmissionController { * created with status = DRAFT. */ @Post('draft') - async saveDraft(@Body() dto: SaveDraftDto) { - return this.submissionService.saveDraft(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.ADMIN) + async saveDraft(@Body() dto: SaveDraftDto, @Req() req: AuthedRequest) { + return this.submissionService.saveDraft({ ...dto, userId: req.user.sub }); } /** @@ -111,7 +174,12 @@ export class SubmissionController { * workflow. Returns 400 if the submission is not a draft. */ @Post(':id/publish') - async publishDraft(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async publishDraft(@Param('id', ParseUUIDPipe) id: string, @Req() req: AuthedRequest) { + const submission = await this.submissionService.findById(id); + if (!submission) throw new NotFoundException('Submission not found'); + assertOwnerOrStaff(req.user, submission.userId, [UserRole.TUTOR, UserRole.ADMIN], 'submission'); return this.submissionService.publishDraft(id); } @@ -126,11 +194,15 @@ export class SubmissionController { * submission's status, score, and feedback to keep them in sync. */ @Post(':id/grade') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async saveGradingResult( @Param('id', ParseUUIDPipe) id: string, @Body() dto: SaveGradingResultDto, + @Req() req: AuthedRequest, ) { - return this.gradingResultService.saveResult(id, dto); + // The grader identity always comes from the JWT. + return this.gradingResultService.saveResult(id, { ...dto, graderId: req.user.sub }); } /** @@ -139,6 +211,8 @@ export class SubmissionController { * Retrieve all grading results for a submission, oldest-first. */ @Get(':id/grades') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getGradingResults(@Param('id', ParseUUIDPipe) id: string) { return this.gradingResultService.getResultsBySubmission(id); } @@ -149,6 +223,8 @@ export class SubmissionController { * Retrieve only the most recent grading result for a submission. */ @Get(':id/grades/latest') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getLatestGradingResult(@Param('id', ParseUUIDPipe) id: string) { return this.gradingResultService.getLatestResult(id); } @@ -159,6 +235,8 @@ export class SubmissionController { * Retrieve a single grading result by its own ID. */ @Get('grades/:gradeId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getGradingResultById(@Param('gradeId', ParseUUIDPipe) gradeId: string) { return this.gradingResultService.getResultById(gradeId); } @@ -170,7 +248,9 @@ export class SubmissionController { */ @Delete('grades/:gradeId') @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async deleteGradingResult(@Param('gradeId', ParseUUIDPipe) gradeId: string) { await this.gradingResultService.deleteResult(gradeId); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/submissions/tutor-review.controller.ts b/BackendAcademy/src/submissions/tutor-review.controller.ts index 4bc90e76f..1480921b0 100644 --- a/BackendAcademy/src/submissions/tutor-review.controller.ts +++ b/BackendAcademy/src/submissions/tutor-review.controller.ts @@ -17,9 +17,12 @@ import { TutorReviewService, ReviewQueuePage, ReviewStats } from './tutor-review import { ReviewSubmissionDto } from './dto/review-submission.dto'; import { ReviewQueueQueryDto } from './dto/review-queue-query.dto'; import { JwtTutorGuard } from '../auth/guards/jwt-tutor.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/enums/user-role.enum'; -type AuthedRequest = Request & { tutor: JwtPayload }; +type AuthedRequest = Request & { user: JwtPayload }; /** * Tutor Review Queue API @@ -36,7 +39,8 @@ type AuthedRequest = Request & { tutor: JwtPayload }; * │ POST /tutor/review/:id Review a sub │ * └──────────────────────────────────────────────────────────┘ */ -@UseGuards(JwtTutorGuard) +@UseGuards(JwtTutorGuard, RolesGuard) +@Roles(UserRole.TUTOR) @Controller('tutor/review') export class TutorReviewController { constructor(private readonly tutorReviewService: TutorReviewService) {} @@ -106,7 +110,7 @@ export class TutorReviewController { @Req() req: AuthedRequest, @Query() query: ReviewQueueQueryDto, ): Promise { - return this.tutorReviewService.getReviewedByTutor(req.tutor.sub, query); + return this.tutorReviewService.getReviewedByTutor(req.user.sub, query); } // ─── Review action ──────────────────────────────────────────────────────── @@ -134,6 +138,6 @@ export class TutorReviewController { @Req() req: AuthedRequest, @Body() dto: ReviewSubmissionDto, ) { - return this.tutorReviewService.reviewSubmission(id, req.tutor.sub, dto); + return this.tutorReviewService.reviewSubmission(id, req.user.sub, dto); } } diff --git a/BackendAcademy/src/users/tutor-profile.controller.ts b/BackendAcademy/src/users/tutor-profile.controller.ts index 79b2956b8..8c447b59b 100644 --- a/BackendAcademy/src/users/tutor-profile.controller.ts +++ b/BackendAcademy/src/users/tutor-profile.controller.ts @@ -7,22 +7,50 @@ import { Body, Param, ParseUUIDPipe, + UseGuards, + Req, + NotFoundException, + ForbiddenException, } from '@nestjs/common'; +import { Request } from 'express'; import { TutorProfileService } from './tutor-profile.service'; import { CreateTutorProfileDto } from './dto/create-tutor-profile.dto'; import { UpdateTutorProfileDto } from './dto/update-tutor-profile.dto'; import { RateTutorDto } from './dto/rate-tutor.dto'; -import { VerifyTutorDto } from './dto/verify-tutor.dto'; -import { RequestVerificationDto } from './dto/request-verification.dto'; import { TutorProfileEntity } from './tutor-profile.entity'; +import { + JwtAuthGuard, + JwtAdminGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, + assertSameSubject, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * Tutor profile API. + * + * Public surface (browse/list verified tutors) stays open. Privileged + * routes declare their required roles via @Roles and verify that a tutor + * only touches their own profile via subject-ownership checks. + */ @Controller('tutors') export class TutorProfileController { constructor(private readonly tutorService: TutorProfileService) {} @Post() - async create(@Body() dto: CreateTutorProfileDto): Promise { - return this.tutorService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) + async create( + @Body() dto: CreateTutorProfileDto, + @Req() req: AuthedRequest, + ): Promise { + // The profile owner is always the authenticated subject; never trust a + // client-supplied userId. + return this.tutorService.create({ ...dto, userId: req.user.sub }); } // ---- Static collection routes (MUST come before /:id) ----------------- @@ -38,6 +66,8 @@ export class TutorProfileController { } @Get('pending') + @UseGuards(JwtAdminGuard, RolesGuard) + @Roles(UserRole.ADMIN) async listPending(): Promise { return this.tutorService.findPending(); } @@ -62,26 +92,55 @@ export class TutorProfileController { } @Get(':id/earnings') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async getEarningsSummary( @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthedRequest, ): Promise> { + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + assertSameSubject(req.user, profile.userId, 'tutor profile'); return this.tutorService.getEarningsSummary(id); } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTutorProfileDto, + @Req() req: AuthedRequest, ): Promise { + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + assertSameSubject(req.user, profile.userId, 'tutor profile'); return this.tutorService.update(id, dto); } @Post(':id/rate') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async rate( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RateTutorDto, + @Req() req: AuthedRequest, ): Promise { - return this.tutorService.rate(id, dto); + const profile = await this.tutorService.findById(id); + if (!profile) { + throw new NotFoundException('Tutor profile not found'); + } + if (profile.userId === req.user.sub) { + throw new ForbiddenException({ + error: 'SELF_RATE_FORBIDDEN', + message: 'You cannot rate your own tutor profile', + }); + } + return this.tutorService.rate(id, { ...dto, raterUserId: req.user.sub }); } @Get(':id/reviews') diff --git a/BackendAcademy/src/users/tutor-profile.module.ts b/BackendAcademy/src/users/tutor-profile.module.ts index c190f07c3..d7db531e3 100644 --- a/BackendAcademy/src/users/tutor-profile.module.ts +++ b/BackendAcademy/src/users/tutor-profile.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { TutorProfileController } from './tutor-profile.controller'; import { TutorProfileService } from './tutor-profile.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [TutorProfileController], providers: [TutorProfileService], exports: [TutorProfileService], diff --git a/BackendAcademy/src/users/user-profile.controller.ts b/BackendAcademy/src/users/user-profile.controller.ts index eb94863fe..46d6af77e 100644 --- a/BackendAcademy/src/users/user-profile.controller.ts +++ b/BackendAcademy/src/users/user-profile.controller.ts @@ -7,17 +7,43 @@ import { Body, Param, ParseUUIDPipe, + UseGuards, + Req, + BadRequestException, } from '@nestjs/common'; +import { Request } from 'express'; import { UserProfileService } from './user-profile.service'; import { UserProfileEntity } from './user-profile.entity'; +import { + JwtAuthGuard, + RolesGuard, + Roles, + UserRole, + JwtPayload, + assertSameSubject, +} from '../auth'; + +type AuthedRequest = Request & { user: JwtPayload }; +/** + * User profile API. + * + * Reads stay public; writes are gated on authentication and ownership so + * a user can only create/edit/delete their own profile. + */ @Controller('user-profiles') export class UserProfileController { constructor(private readonly profileService: UserProfileService) {} @Post() - async create(@Body() dto: Partial) { - return this.profileService.create(dto); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async create( + @Body() dto: Partial, + @Req() req: AuthedRequest, + ) { + // The subject always comes from the JWT; ignore any client-supplied userId. + return this.profileService.create({ ...dto, userId: req.user.sub }); } @Get() @@ -36,15 +62,34 @@ export class UserProfileController { } @Put(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) async update( @Param('id', ParseUUIDPipe) id: string, @Body() updates: Partial, + @Req() req: AuthedRequest, ) { - return this.profileService.update(id, updates); + const profile = await this.profileService.findById(id); + if (!profile) { + throw new BadRequestException({ statusCode: 404, message: 'Profile not found' }); + } + assertSameSubject(req.user, profile.userId, 'profile'); + // Never allow reassigning the profile to another subject. + return this.profileService.update(id, { ...updates, userId: profile.userId }); } @Delete(':id') - async remove(@Param('id', ParseUUIDPipe) id: string) { + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + async remove( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: AuthedRequest, + ) { + const profile = await this.profileService.findById(id); + if (!profile) { + throw new BadRequestException({ statusCode: 404, message: 'Profile not found' }); + } + assertSameSubject(req.user, profile.userId, 'profile'); return this.profileService.remove(id); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/users/user-profile.module.ts b/BackendAcademy/src/users/user-profile.module.ts index bf92a083b..ff4a31611 100644 --- a/BackendAcademy/src/users/user-profile.module.ts +++ b/BackendAcademy/src/users/user-profile.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { UserProfileController } from './user-profile.controller'; import { UserProfileService } from './user-profile.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], controllers: [UserProfileController], providers: [UserProfileService], exports: [UserProfileService], diff --git a/BackendAcademy/src/users/users.controller.ts b/BackendAcademy/src/users/users.controller.ts index e55c1d520..60d865093 100644 --- a/BackendAcademy/src/users/users.controller.ts +++ b/BackendAcademy/src/users/users.controller.ts @@ -1,15 +1,32 @@ -import { Body, Controller, Param, Put } from '@nestjs/common'; +import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common'; import { UsersService, UserPreferencesDto } from './users.service'; +import { + JwtAuthGuard, + RolesGuard, + SubjectOwnershipGuard, + Roles, + Ownership, + UserRole, +} from '../auth'; +/** + * Users API. + * + * `PUT /users/:userId/preferences` is subject-owned: callers may only + * update their own preferences unless they are an admin. + */ @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Put(':userId/preferences') + @UseGuards(JwtAuthGuard, RolesGuard, SubjectOwnershipGuard) + @Roles(UserRole.LEARNER, UserRole.TUTOR, UserRole.ADMIN) + @Ownership('userId') async updatePreferences( @Param('userId') userId: string, @Body() dto: UserPreferencesDto, ) { return this.usersService.updatePreferences(userId, dto); } -} +} \ No newline at end of file diff --git a/BackendAcademy/src/users/users.service.ts b/BackendAcademy/src/users/users.service.ts index f2b18c834..930c17283 100644 --- a/BackendAcademy/src/users/users.service.ts +++ b/BackendAcademy/src/users/users.service.ts @@ -235,13 +235,18 @@ export class UsersService { return this.deletedUsers.has(userId); } - async onPasswordChanged(userId: string): Promise { +async onPasswordChanged(userId: string): Promise { await this.authSessionService?.onPasswordChanged(userId); } async onPasswordReset(userId: string): Promise { await this.authSessionService?.onPasswordReset(userId); } + + /** + * Records an asset upload for a user. + */ + private trackUserUpload(userId: string, assetId: string): void { if (!this.userUploads.has(userId)) { this.userUploads.set(userId, new Set()); }