diff --git a/BackendAcademy/src/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts index fe5ef4e39..8c61f9ee3 100644 --- a/BackendAcademy/src/auth/auth-session.controller.ts +++ b/BackendAcademy/src/auth/auth-session.controller.ts @@ -16,22 +16,10 @@ import { AuthTokensResponse, Session } from './interfaces/session.interface'; import { UserRole } from './enums/user-role.enum'; import { AntiCheatService } from '../security/anti-cheat.service'; -/** - * AuthSessionController — Issue #220, #410 - * - * Exposes session-management and API key workflow endpoints: - * - * POST /auth/session/login — issue access + refresh token pair - * POST /auth/session/refresh — rotate refresh token, return new pair - * POST /auth/session/logout — revoke single session - * POST /auth/session/logout-all — revoke all sessions for a user - * GET /auth/session/:userId — list active sessions - * - * POST /auth/api-keys — issue a new API key - * GET /auth/api-keys/:userId — list a user's API keys - * POST /auth/api-keys/:keyId/revoke — revoke an API key - * POST /auth/api-keys/:keyId/rotate — rotate an API key - */ +export class HeartbeatDto { + sessionId: string; +} + @Controller('auth/session') export class AuthSessionController { constructor( @@ -54,16 +42,23 @@ export class AuthSessionController { return this.authSessionService.refreshTokens(dto.refreshToken); } + @Post('heartbeat') + @HttpCode(HttpStatus.OK) + async heartbeat(@Body() dto: HeartbeatDto): Promise<{ updated: boolean }> { + await this.authSessionService.updateLastActivity(dto.sessionId); + return { updated: true }; + } + @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) - logout(@Query('sessionId') sessionId: string): void { - this.authSessionService.revokeSession(sessionId); + async logout(@Query('sessionId') sessionId: string): Promise { + await this.authSessionService.revokeSession(sessionId); } @Post('logout-all') @HttpCode(HttpStatus.NO_CONTENT) - logoutAll(@Query('userId') userId: string): void { - this.authSessionService.revokeAllUserSessions(userId); + async logoutAll(@Query('userId') userId: string): Promise { + await this.authSessionService.revokeAllUserSessions(userId); } @Get(':userId') diff --git a/BackendAcademy/src/auth/auth-session.service.spec.ts b/BackendAcademy/src/auth/auth-session.service.spec.ts index b33099199..2859802cb 100644 --- a/BackendAcademy/src/auth/auth-session.service.spec.ts +++ b/BackendAcademy/src/auth/auth-session.service.spec.ts @@ -39,13 +39,18 @@ describe('AuthSessionService security revocation', () => { }); it('revokes every session after refresh-token reuse', async () => { + const now = Date.now(); const sessionFactory = (sessionId: string): Session => ({ sessionId, userId: 'user-1', role: UserRole.LEARNER, refreshTokenHash: 'hash-of-different-token', - createdAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), + createdAt: new Date(now), + expiresAt: new Date(now + 60_000), + absoluteExpiresAt: new Date(now + 60_000 + 300_000), + idleExpiresAt: new Date(now + 86_400_000), + deliveryGraceSeconds: 300, + lastActivityAt: new Date(now), revoked: false, }); const redis = (service as unknown as { redis: RedisService }).redis; @@ -68,4 +73,57 @@ describe('AuthSessionService security revocation', () => { expect(await service.getActiveSessions('user-1')).toHaveLength(0); expect(revokeAllUserSessions).toHaveBeenCalledWith('user-1', 'token_reuse'); }); + + it('updates lastActivityAt on valid activity', async () => { + const now = Date.now(); + const session: Session = { + sessionId: 'session-1', + userId: 'user-1', + role: UserRole.LEARNER, + refreshTokenHash: 'hash', + createdAt: new Date(now), + expiresAt: new Date(now + 60_000), + absoluteExpiresAt: new Date(now + 300_000), + idleExpiresAt: new Date(now + 86_400_000), + deliveryGraceSeconds: 300, + lastActivityAt: new Date(now), + revoked: false, + }; + const redis = (service as unknown as { redis: RedisService }).redis; + await redis.set('session:session-1', JSON.stringify(session)); + await redis.sadd('userSessions:user-1', 'session-1'); + + await service.validateSession('session-1'); + + const stored = JSON.parse(await redis.get('session:session-1') as string) as Session; + expect(stored.revoked).toBe(false); + expect(new Date(stored.lastActivityAt).getTime()).toBeGreaterThanOrEqual(now); + }); + + it('revokes idle sessions on validation', async () => { + const now = Date.now(); + const session: Session = { + sessionId: 'session-1', + userId: 'user-1', + role: UserRole.LEARNER, + refreshTokenHash: 'hash', + createdAt: new Date(now - 100_000), + expiresAt: new Date(now + 60_000), + absoluteExpiresAt: new Date(now + 300_000), + idleExpiresAt: new Date(now - 10_000), + deliveryGraceSeconds: 300, + lastActivityAt: new Date(now - 90_000_000), + revoked: false, + }; + const redis = (service as unknown as { redis: RedisService }).redis; + await redis.set('session:session-1', JSON.stringify(session)); + await redis.sadd('userSessions:user-1', 'session-1'); + + await expect(service.validateSession('session-1')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + + const stored = JSON.parse(await redis.get('session:session-1') as string) as Session; + expect(stored.revoked).toBe(true); + }); }); diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts index 7663ffd13..131f20569 100644 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ b/BackendAcademy/src/auth/auth-session.service.ts @@ -1,10 +1,7 @@ -### BackendAcademy/src/auth/auth-session.service.ts - import { Injectable, UnauthorizedException, Logger, - Inject, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuditLogService } from '../audit/audit.service'; @@ -13,8 +10,8 @@ import { randomUUID, createHash } from 'crypto'; import { UserRole } from './enums/user-role.enum'; import { JwtPayload } from './interfaces/jwt-payload.interface'; import { - AuthTokensResponse, RefreshTokenPayload, + AuthTokensResponse, Session, } from './interfaces/session.interface'; import { RedisService } from '../redis/redis.service'; @@ -42,64 +39,66 @@ const DEFAULT_SESSION_POLICY: SessionPolicy = { @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 redis: RedisService, 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), - refreshTokenTtl: this.configService.get('SESSION_REFRESH_TOKEN_TTL', DEFAULT_SESSION_POLICY.refreshTokenTtl), - deliveryGracePeriod: this.configService.get('SESSION_DELIVERY_GRACE_PERIOD', DEFAULT_SESSION_POLICY.deliveryGracePeriod), - maxConcurrentSessions: this.configService.get('SESSION_MAX_CONCURRENT', DEFAULT_SESSION_POLICY.maxConcurrentSessions), - singleSessionMode: this.configService.get('SESSION_SINGLE_MODE', DEFAULT_SESSION_POLICY.singleSessionMode), - requireDeviceFingerprint: this.configService.get('SESSION_REQUIRE_DEVICE', DEFAULT_SESSION_POLICY.requireDeviceFingerprint), - idleSessionTimeout: this.configService.get('SESSION_IDLE_TIMEOUT', DEFAULT_SESSION_POLICY.idleSessionTimeout), + accessTokenTtl: this.configService.get( + 'SESSION_ACCESS_TOKEN_TTL', + DEFAULT_SESSION_POLICY.accessTokenTtl, + ), + refreshTokenTtl: this.configService.get( + 'SESSION_REFRESH_TOKEN_TTL', + DEFAULT_SESSION_POLICY.refreshTokenTtl, + ), + deliveryGracePeriod: this.configService.get( + 'SESSION_DELIVERY_GRACE_PERIOD', + DEFAULT_SESSION_POLICY.deliveryGracePeriod, + ), + maxConcurrentSessions: this.configService.get( + 'SESSION_MAX_CONCURRENT', + DEFAULT_SESSION_POLICY.maxConcurrentSessions, + ), + singleSessionMode: this.configService.get( + 'SESSION_SINGLE_MODE', + DEFAULT_SESSION_POLICY.singleSessionMode, + ), + 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'); + this.accessSecret = this.configService.get( + 'JWT_ACCESS_SECRET', + 'default-access-secret', + ); + this.refreshSecret = this.configService.get( + 'JWT_REFRESH_SECRET', + 'default-refresh-secret', + ); } private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } - // --------------------------------------------------------------------------- - // -------------------------------------------------------------------------------------------- - // #350: Public policy access - // ------------------------------------------------------------------------------------------- - - /** - * Returns the current session policy for external consumers. - */ getSessionPolicy(): Readonly { return { ...this.sessionPolicy }; } - // -------------------------------------------------------------------------------------------- - // Public API - // -------------------------------------------------------------------------------------------- - - /** - * Creates a new session for the given user. - * Optionally records a device fingerprint for trusted-device recognition. - */ async createSession( userId: string, role: UserRole, @@ -107,9 +106,17 @@ export class AuthSessionService { ): Promise { 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 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; if (this.sessionPolicy.singleSessionMode) { await this.revokeAllUserSessions(userId); @@ -128,30 +135,46 @@ 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, + absoluteExpiresAt: new Date( + now.getTime() + + this.sessionPolicy.refreshTokenTtl * 1000 + + this.sessionPolicy.deliveryGracePeriod * 1000, + ), + idleExpiresAt: new Date( + now.getTime() + this.sessionPolicy.idleSessionTimeout * 1000, + ), + deliveryGraceSeconds: this.sessionPolicy.deliveryGracePeriod, + lastActivityAt: now, revoked: false, deviceHash, isTrustedDevice: deviceHash ? await this.isTrustedDevice(userId, deviceHash) : undefined, - lastUsedAt: now, }; await this.setSession(session); - if (deviceHash) await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); + if (deviceHash) { + await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); + } if (deviceHash && !(await this.isTrustedDevice(userId, deviceHash))) { this.logger.warn(`New device login for user ${userId}`); } - await this.auditService.create({ action: 'login', actor: userId, outcome: 'SUCCESS', session: sessionId, requestContext: { deviceHash } }); + await this.auditService.create({ + action: 'login', + actor: userId, + outcome: 'SUCCESS', + session: sessionId, + requestContext: { deviceHash }, + }); return this.buildTokensResponse(accessToken, refreshToken); } @@ -171,13 +194,14 @@ 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') { + const existingClaim = await this.redis.get(claimKey); + if (existingClaim) { throw new UnauthorizedException({ error: 'SESSION_NOT_FOUND', message: 'Session has been revoked or does not exist', }); } + await this.redis.set(claimKey, randomUUID(), 30_000); const session = await this.getSession(payload.sessionId); if (!session || session.revoked) { @@ -189,6 +213,7 @@ export class AuthSessionService { } if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { + await this.revokeAllUserSessions(session.userId, 'token_reuse'); session.revoked = true; await this.setSession(session); await this.redis.del(claimKey); @@ -198,10 +223,8 @@ export class AuthSessionService { }); } - if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - // A replay indicates that the user's 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)) { + const now = new Date(); + if (this.isSessionExpired(session, now)) { session.revoked = true; await this.setSession(session); await this.redis.del(claimKey); @@ -211,78 +234,105 @@ export class AuthSessionService { }); } - // This write is inside the per-session lock, so only one concurrent - // request can observe and consume the valid refresh token. + if (this.isSessionIdle(session, now)) { + session.revoked = true; + await this.setSession(session); + await this.redis.del(claimKey); + throw new UnauthorizedException({ + error: 'SESSION_IDLE_TIMEOUT', + message: 'Session has been idle for too long; please log in again', + }); + } + session.revoked = true; await this.setSession(session); await this.redis.del(claimKey); + + await this.auditService.create({ + action: 'refresh', + actor: session.userId, + outcome: 'SUCCESS', + session: session.sessionId, + }); 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)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired; please log in again', - }); - } + async validateSession(sessionId: string): Promise { + return this.withRefreshLock(sessionId, async () => { + const session = await this.getSession(sessionId); + if (!session) { + throw new UnauthorizedException({ + error: 'SESSION_NOT_FOUND', + message: 'Session does not exist', + }); + } + if (session.revoked) { + throw new UnauthorizedException({ + error: 'SESSION_REVOKED', + message: 'Session has been revoked', + }); + } - // 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', - }); - } + const now = new Date(); + if (this.isSessionExpired(session, now)) { + session.revoked = true; + await this.setSession(session); + throw new UnauthorizedException({ + error: 'SESSION_EXPIRED', + message: 'Session has expired; please log in again', + }); + } - // Revoke the old session before issuing new tokens (rotation). - session.revoked = true; - await this.setSession(session); + 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', + }); + } - await this.auditService.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); - return await this.createSession(session.userId, session.role); + const previousActivity = session.lastActivityAt + ? new Date(session.lastActivityAt).getTime() + : 0; + const newActivity = Date.now(); + if (newActivity > previousActivity) { + session.lastActivityAt = new Date(newActivity); + session.idleExpiresAt = new Date( + newActivity + this.sessionPolicy.idleSessionTimeout * 1000, + ); + await this.setSession(session); + } + + return session; + }); + } + + async validateAndRefreshSession(sessionId: string): Promise { + return this.validateSession(sessionId); + } + + async updateLastActivity(sessionId: string): Promise { + await this.validateSession(sessionId); } - /** - * Revokes a single session (logout from current device). - * 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, + }); } } - /** - * Revokes all active sessions for a user (logout from all devices). - * 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 +344,12 @@ 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,37 +370,28 @@ 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. - */ - async getActiveSessions(userId: string): Promise[]> { - 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; + if ( + session && + session.userId === userId && + !session.revoked && + !this.isSessionExpired(session, now) && + !this.isSessionIdle(session, now) + ) { + const { refreshTokenHash, ...rest } = session; result.push(rest); } } return result; } - // ------------------------------------------------------------------------------------------- - // Device binding & trusted device recognition - // -------------------------------------------------------------------------------------------- - hashDevice(fingerprint: string): string { return createHash('sha256').update(fingerprint).digest('hex'); } @@ -357,33 +403,43 @@ 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 } }); - } - - async getTrustedDevices(userId: string): Promise { - return await this.redis.smembers(`trustedDevices:${userId}`); + this.auditService.create({ + action: 'remove_trusted_device', + actor: userId, + outcome: 'SUCCESS', + requestContext: { deviceHash }, + }); } async getTrustedDevices(userId: string): Promise { return this.redis.smembers(`trustedDevices:${userId}`); } - async checkDeviceTrust(userId: string, deviceFingerprint: string): Promise<{ trusted: boolean; deviceHash: string }> { + 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'); + return { + trusted: await this.isTrustedDevice(userId, deviceHash), + deviceHash, + }; } - private async withRefreshLock(sessionId: string, operation: () => Promise): Promise { + private async withRefreshLock( + sessionId: string, + operation: () => Promise, + ): Promise { const previous = this.refreshLocks.get(sessionId) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { @@ -417,50 +473,54 @@ export class AuthSessionService { const session = JSON.parse(data as string) as Session; session.createdAt = new Date(session.createdAt); session.expiresAt = new Date(session.expiresAt); + session.absoluteExpiresAt = new Date(session.absoluteExpiresAt); + session.idleExpiresAt = new Date(session.idleExpiresAt); + session.lastActivityAt = new Date(session.lastActivityAt); + if (session.revokedAt) { + session.revokedAt = new Date(session.revokedAt); + } return session; } - 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( 1, - Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + this.sessionPolicy.deliveryGracePeriod, + Math.floor( + (session.expiresAt.getTime() - Date.now()) / 1000, + ) + this.sessionPolicy.deliveryGracePeriod, + ); + await this.redis.set( + this.sessionKey(session.sessionId), + JSON.stringify(session), + ttlSeconds * 1000, + ); + await this.redis.sadd( + this.userSessionsKey(session.userId), + session.sessionId, ); - await this.redis.set(this.sessionKey(session.sessionId), JSON.stringify(session), 'EX', ttlSeconds); - 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 } }); } - // -------------------------------------------------------------------------------------------- - // Private helpers - // -------------------------------------------------------------------------------------------- - private async signTokenPair( userId: string, role: UserRole, sessionId: string, ): Promise<{ accessToken: string; refreshToken: string }> { - const accessPayload: JwtPayload = { sub: userId, role }; - const refreshPayload: RefreshTokenPayload = { sub: userId, role, sessionId }; + const accessPayload: JwtPayload = { + sub: userId, + role, + sessionId, + type: 'access', + }; + const refreshPayload: RefreshTokenPayload = { + sub: userId, + role, + sessionId, + type: 'refresh', + }; + const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync(accessPayload, { + secret: this.accessSecret, expiresIn: this.sessionPolicy.accessTokenTtl, }), this.jwtService.signAsync(refreshPayload, { @@ -468,23 +528,14 @@ export class AuthSessionService { 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 +543,20 @@ 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); + const expiryWithGrace = 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; + private isSessionIdle(session: Session, now: Date): boolean { + const lastActivityAt = new Date(session.lastActivityAt); + return ( + now.getTime() - lastActivityAt.getTime() > + this.sessionPolicy.idleSessionTimeout * 1000 + ); } } diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts index c907caeb4..78f6b8e61 100644 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts @@ -1,15 +1,21 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, ForbiddenException } from '@nestj/common'; -import { SessionService } from '../session.service'; -import { JstService } from '@nestjs/jstt'; +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, + ForbiddenException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; -import { JstPayload } from '../interfaces/jstt-payload.interface'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; import { UserRole } from '../enums/user-role.enum'; +import { AuthSessionService } from '../auth-session.service'; @Injectable() export class JwtLearnerGuard implements CanActivate { constructor( - private readonly jwtService: JstService, - private readonly sessionService: SessionService, + private readonly jwtService: JwtService, + private readonly authSessionService: AuthSessionService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -17,26 +23,49 @@ export class JwtLearnerGuard implements CanActivate { const token = this.extractBearerToken(request); if (!token) { - throw new UnauthorizedException({ error: 'MISSING_TOKEN', message: 'Authorization header with Bearer token is required' }); + 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); + payload = await this.jwtService.verifyAsync< + JwtPayload & { sessionId?: string } + >(token); } catch { - throw new UnauthorizedException({ error: 'INVALID_TOKEN', message: 'Token is invalid or has expired' }); + 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' }); + 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' }); + 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); + try { + await this.authSessionService.validateSession(payload.sessionId); + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException({ + error: 'INVALID_SESSION', + message: 'Session is expired, revoked, or inactive', + }); + } (request as Request & { user: JwtPayload }).user = payload; return true; diff --git a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts index 396ab57c5..3e1c5ab81 100644 --- a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts @@ -40,7 +40,7 @@ export class JwtTutorGuard implements CanActivate { let payload: JwtPayload; try { - payload = await this&jwtService.verifyAsync(token); + payload = await this.jwtService.verifyAsync(token); } catch { throw new UnauthorizedException({ error: 'INVALID_TOKEN', @@ -48,7 +48,7 @@ export class JwtTutorGuard implements CanActivate { }); } - if (payload.role !== UserRole.TUTIOR) { + if (payload.role !== UserRole.TUTOR) { throw new ForbiddenException({ error: 'TUTIOR_ROLE_REQUIRED', message: 'Only tutors are allowed to access this resource', diff --git a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts b/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts index fa6784e69..ab749a3ac 100644 --- a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts +++ b/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts @@ -3,6 +3,8 @@ import { UserRole } from '../enums/user-role.enum'; export interface JwtPayload { sub: string; role: UserRole; + sessionId: string; + type: 'access' | 'refresh'; iat?: number; exp?: number; } diff --git a/BackendAcademy/src/auth/interfaces/session.interface.ts b/BackendAcademy/src/auth/interfaces/session.interface.ts index 8e9d0b236..ef7af6054 100644 --- a/BackendAcademy/src/auth/interfaces/session.interface.ts +++ b/BackendAcademy/src/auth/interfaces/session.interface.ts @@ -1,48 +1,32 @@ import { UserRole } from '../enums/user-role.enum'; -/** - * Represents a stored session record, persisted in a durable shared backend - * (e.g., Redis or a database) so sessions survive restarts -and are visible across all replicas. - */ +export interface RefreshTokenPayload { + sub: string; + role: UserRole; + sessionId: string; + type: 'refresh'; +} + +export interface AuthTokensResponse { + accessToken: string; + refreshToken: string; + tokenType: string; + expiresIn: number; +} export interface Session { - /** Unique session identifier (also stored inside the refresh token payload). */ sessionId: string; - - /** Owner of the session. */ userId: string; - - /** Role associated with the session. */ role: UserRole; - - /** SHA-256 hash of the refresh token (never store raw token). */ refreshTokenHash: string; - - /** When this session was first created. */ createdAt: Date; - - /** When the refresh token expires. */ expiresAt: Date; - - /** Absolute maximum lifetime of the session, independent of JWT exp. */ absoluteExpiresAt: Date; - - /** Timestamp after which the session is considered idle-expired if no activity. */ idleExpiresAt: Date; - - /** Grace period in seconds allowed for token delivery after expiry (clock skew buffer). */ deliveryGraceSeconds: number; - - /** Flag set to true once the session is revoked (logout / rotation). */ + lastActivityAt: Date; revoked: boolean; - - /** Timestamp when the session was revoked. */ revokedAt?: Date; - - /** SHA-256 hash of the device fingerprint (if device binding is enabled). */ deviceHash?: string; - - /** Whether the device has been previously trusted by this user. */ isTrustedDevice?: boolean; } diff --git a/BackendAcademy/src/auth/jwt-clock-skew.spec.ts b/BackendAcademy/src/auth/jwt-clock-skew.spec.ts index 2f5a285ef..ec9ca174a 100644 --- a/BackendAcademy/src/auth/jwt-clock-skew.spec.ts +++ b/BackendAcademy/src/auth/jwt-clock-skew.spec.ts @@ -2,9 +2,16 @@ import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Request } from 'express'; import { JwtAdminGuard } from './guards/jwt-admin.guard'; +import { AuthSessionService } from './auth-session.service'; import { UserRole } from './enums/user-role.enum'; import { JwtPayload } from './interfaces/jwt-payload.interface'; +function buildMockAuthSessionService() { + return { + validateSession: jest.fn().mockResolvedValue(undefined), + } as unknown as AuthSessionService; +} + /** * BA-023 — Token clock-skew policy. * @@ -116,10 +123,10 @@ describe('JWT clock skew policy (BA-023)', () => { describe('guards apply the same policy end-to-end', () => { it('allows an admin token that has just expired within the tolerance', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN }); + const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN, sessionId: 'test-session' }); jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z')); - const guard = new JwtAdminGuard(jwtService); + const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); const { context, request } = makeContextWithToken(token); await expect(guard.canActivate(context)).resolves.toBe(true); @@ -127,10 +134,10 @@ describe('JWT clock skew policy (BA-023)', () => { }); it('rejects an admin token that expired beyond the tolerance', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN }); + const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN, sessionId: 'test-session' }); jest.setSystemTime(new Date('2026-01-08T00:01:30.000Z')); - const guard = new JwtAdminGuard(jwtService); + const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); const { context } = makeContextWithToken(token); await expect(guard.canActivate(context)).rejects.toBeInstanceOf( @@ -139,10 +146,10 @@ describe('JWT clock skew policy (BA-023)', () => { }); it('still enforces role checks within the allowed skew', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.LEARNER }); + const token = jwtService.sign({ sub: 'u1', role: UserRole.LEARNER, sessionId: 'test-session' }); jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z')); - const guard = new JwtAdminGuard(jwtService); + const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); const { context } = makeContextWithToken(token); await expect(guard.canActivate(context)).rejects.toBeInstanceOf(