diff --git a/BackendAcademy/package-lock.json b/BackendAcademy/package-lock.json index a4197eab6..6ca21e1b2 100644 --- a/BackendAcademy/package-lock.json +++ b/BackendAcademy/package-lock.json @@ -20,6 +20,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", @@ -997,6 +998,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4275,6 +4282,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4669,6 +4685,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6162,6 +6187,28 @@ "node": ">=12.0.0" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9488,6 +9535,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -10131,6 +10199,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", 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/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts index fe5ef4e39..1cd25e326 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, @@ -45,13 +44,17 @@ export class AuthSessionController { return this.authSessionService.createSession( dto.userId, dto.role as UserRole, + dto.deviceFingerprint, ); } @Post('refresh') @HttpCode(HttpStatus.OK) async refresh(@Body() dto: RefreshTokenDto): Promise { - return this.authSessionService.refreshTokens(dto.refreshToken); + return this.authSessionService.refreshTokens( + dto.refreshToken, + dto.deviceFingerprint, + ); } @Post('logout') @@ -70,7 +73,7 @@ export class AuthSessionController { @HttpCode(HttpStatus.OK) getActiveSessions( @Param('userId') userId: string, - ): Omit[] { + ): Omit[] { return this.authSessionService.getActiveSessions(userId); } diff --git a/BackendAcademy/src/auth/auth-session.service.spec.ts b/BackendAcademy/src/auth/auth-session.service.spec.ts index b33099199..7fa3588c1 100644 --- a/BackendAcademy/src/auth/auth-session.service.spec.ts +++ b/BackendAcademy/src/auth/auth-session.service.spec.ts @@ -1,10 +1,194 @@ import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { AuthSessionService } from './auth-session.service'; import { UserRole } from './enums/user-role.enum'; import { Session } from './interfaces/session.interface'; import { RedisService } from '../redis/redis.service'; import { AuditLogService } from '../audit/audit.service'; +function createRedisMock() { + const values = new Map(); + const sets = new Map>(); + return { + _cache: values, + get: jest.fn(async (key: string) => values.get(key) ?? null), + set: jest.fn(async (key: string, value: string) => { + values.set(key, value); + return 'OK'; + }), + del: jest.fn(async () => 1), + sadd: jest.fn(async (key: string, value: string) => { + const set = sets.get(key) ?? new Set(); + set.add(value); + sets.set(key, set); + return 1; + }), + smembers: jest.fn(async (key: string) => [...(sets.get(key) ?? [])]), + srem: jest.fn(async () => 1), + }; +} + +describe('AuthSessionService refresh token concurrency', () => { + it('accepts a refresh token at most once under concurrent requests', async () => { + const redis = createRedisMock(); + const jwtService = { + verifyAsync: jest.fn(async () => ({ + sub: 'user-1', + role: UserRole.LEARNER, + sessionId: 'session-1', + })), + signAsync: jest.fn(async () => 'new-token'), + } as unknown as JwtService; + const config = { + get: jest.fn((_key: string, fallback: unknown) => fallback), + } as unknown as ConfigService; + const service = new AuthSessionService(jwtService, config, redis as any); + const refreshToken = 'refresh-token'; + const refreshTokenHash = service['hashToken'](refreshToken); + + await redis.set( + 'session:session-1', + JSON.stringify({ + sessionId: 'session-1', + userId: 'user-1', + role: UserRole.LEARNER, + refreshTokenHash, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + revoked: false, + }), + ); + + const results = await Promise.allSettled([ + service.refreshTokens(refreshToken), + service.refreshTokens(refreshToken), + ]); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + expect((results.find((result) => result.status === 'rejected') as PromiseRejectedResult).reason.response).toMatchObject({ + error: 'SESSION_NOT_FOUND', + }); + + const stored = JSON.parse((await redis.get('session:session-1'))!); + expect(stored.revoked).toBe(true); + }); +}); + +describe('AuthSessionService BA-016 device fingerprint policy', () => { + function buildService(requireDevice: boolean) { + const redis = createRedisMock(); + const jwtService = { + verifyAsync: jest.fn(async () => ({ + sub: 'user-1', + role: UserRole.LEARNER, + sessionId: 'session-1', + })), + signAsync: jest.fn(async () => 'new-token'), + } as unknown as JwtService; + const config = { + get: jest.fn((key: string, fallback: unknown) => { + if (key === 'SESSION_REQUIRE_DEVICE') return requireDevice; + return fallback; + }), + } as unknown as ConfigService; + const service = new AuthSessionService(jwtService, config, redis as any); + return { service, redis }; + } + + const sessionKey = 'session:session-1'; + const REFRESH_TOKEN = 'refresh-token'; + const deviceHashOf = (service: AuthSessionService, fp: string) => service['hashDevice'](fp); + + async function seedSession(service: AuthSessionService, redis: any, deviceHash?: string, refreshToken: string = REFRESH_TOKEN) { + await redis.set( + sessionKey, + JSON.stringify({ + sessionId: 'session-1', + userId: 'user-1', + role: UserRole.LEARNER, + refreshTokenHash: service['hashToken'](refreshToken), + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + revoked: false, + deviceHash, + }), + ); + } + + it('always stores a SHA-256 hash of the fingerprint, never the raw value', async () => { + const { service, redis } = buildService(false); + await service.createSession('user-1', UserRole.LEARNER, 'raw-fingerprint'); + + const sessionKey = [...redis._cache.keys()].find((key: string) => key.startsWith('session:'))!; + const stored = JSON.parse((await redis.get(sessionKey))!); + expect(stored.deviceHash).toBe(deviceHashOf(service, 'raw-fingerprint')); + expect(stored.deviceHash).not.toBe('raw-fingerprint'); + expect(JSON.stringify(stored)).not.toContain('raw-fingerprint'); + }); + + it('records only the hashed fingerprint in trusted-device tracking', async () => { + const { service, redis } = buildService(false); + await service.createSession('user-1', UserRole.LEARNER, 'browser-a'); + + const tracked = await redis.smembers('trustedDevices:user-1'); + expect(tracked).toEqual([deviceHashOf(service, 'browser-a')]); + expect(tracked.join(',')).not.toContain('browser-a'); + }); + + it('rejects login without a fingerprint when the policy requires one', async () => { + const { service } = buildService(true); + await expect(service.createSession('user-1', UserRole.LEARNER)).rejects.toHaveProperty( + 'response', + expect.objectContaining({ error: 'DEVICE_FINGERPRINT_REQUIRED' }), + ); + }); + + it('accepts login with a fingerprint when the policy requires one', async () => { + const { service } = buildService(true); + await expect(service.createSession('user-1', UserRole.LEARNER, 'browser-a')).resolves.toBeDefined(); + }); + + it('revokes the session when a refresh token is presented from a different device', async () => { + const { service, redis } = buildService(true); + await seedSession(service, redis, deviceHashOf(service, 'browser-a')); + + await expect(service.refreshTokens(REFRESH_TOKEN, 'browser-b')).rejects.toHaveProperty( + 'response', + expect.objectContaining({ error: 'DEVICE_MISMATCH' }), + ); + + const stored = JSON.parse((await redis.get(sessionKey))!); + expect(stored.revoked).toBe(true); + }); + + it('rotates successfully when the presented fingerprint matches the session', async () => { + const { service, redis } = buildService(true); + await seedSession(service, redis, deviceHashOf(service, 'browser-a')); + + await expect(service.refreshTokens(REFRESH_TOKEN, 'browser-a')).resolves.toBeDefined(); + expect(redis.set).toHaveBeenCalled(); + }); + + it('keeps the device binding across rotation', async () => { + const { service, redis } = buildService(true); + await seedSession(service, redis, deviceHashOf(service, 'browser-a')); + await service.refreshTokens(REFRESH_TOKEN, 'browser-a'); + + const stored = JSON.parse((await redis.get(sessionKey))!); + expect(stored.deviceHash).toBe(deviceHashOf(service, 'browser-a')); + }); + + it('marks trusted devices at login', async () => { + const { service } = buildService(false); + await service.createSession('user-1', UserRole.LEARNER, 'browser-a'); + await service.createSession('user-1', UserRole.LEARNER, 'browser-a'); + + expect(await service.isTrustedDevice('user-1', deviceHashOf(service, 'browser-a'))).toBe(true); + }); +}); + describe('AuthSessionService security revocation', () => { let service: AuthSessionService; let revokeAllUserSessions: jest.SpyInstance; @@ -21,7 +205,7 @@ describe('AuthSessionService security revocation', () => { service = new AuthSessionService( jwtService as never, configService as never, - new RedisService(), + new RedisService() as never, new AuditLogService(), ); revokeAllUserSessions = jest.spyOn(service, 'revokeAllUserSessions'); @@ -68,4 +252,4 @@ describe('AuthSessionService security revocation', () => { expect(await service.getActiveSessions('user-1')).toHaveLength(0); expect(revokeAllUserSessions).toHaveBeenCalledWith('user-1', 'token_reuse'); }); -}); +}); \ No newline at end of file diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts index 7663ffd13..6ccba9ddf 100644 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ b/BackendAcademy/src/auth/auth-session.service.ts @@ -1,10 +1,9 @@ -### BackendAcademy/src/auth/auth-session.service.ts - import { Injectable, UnauthorizedException, Logger, Inject, + Optional, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { AuditLogService } from '../audit/audit.service'; @@ -17,7 +16,7 @@ import { RefreshTokenPayload, Session, } from './interfaces/session.interface'; -import { RedisService } from '../redis/redis.service'; +import { Redis } from 'ioredis'; export interface SessionPolicy { accessTokenTtl: number; @@ -42,26 +41,16 @@ 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 auditService: AuditLogService, + @Inject('REDIS_CLIENT') private readonly redis: Redis, + @Optional() private readonly auditService?: AuditLogService, ) { - this.redis = redisClient; - // #350: Load centralized session policy from config + // #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), @@ -71,20 +60,8 @@ 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 { - return createHash('sha256').update(token).digest('hex'); } - // --------------------------------------------------------------------------- - // -------------------------------------------------------------------------------------------- - // #350: Public policy access - // ------------------------------------------------------------------------------------------- - /** * Returns the current session policy for external consumers. */ @@ -92,13 +69,35 @@ export class AuthSessionService { return { ...this.sessionPolicy }; } - // -------------------------------------------------------------------------------------------- - // Public API - // -------------------------------------------------------------------------------------------- + /** + * BA-016: Device fingerprint policy (DEVICE_FINGERPRINT_REQUIRED). + * + * When `SESSION_REQUIRE_DEVICE` is enabled a fingerprint is mandatory for + * session creation. When a fingerprint is supplied it is always hashed and + * the raw value is never stored or logged. + * + * Returns the device hash when a fingerprint is present, otherwise + * `undefined` (only allowed while the policy is disabled). + */ + private enforceDeviceFingerprintPolicy(deviceFingerprint?: string): string | undefined { + if (deviceFingerprint && deviceFingerprint.trim().length > 0) { + return this.hashDevice(deviceFingerprint); + } + if (this.sessionPolicy.requireDeviceFingerprint) { + throw new UnauthorizedException({ + error: 'DEVICE_FINGERPRINT_REQUIRED', + message: 'A device fingerprint is required when device fingerprint enforcement is enabled', + }); + } + return undefined; + } /** * Creates a new session for the given user. - * Optionally records a device fingerprint for trusted-device recognition. + * + * The `deviceFingerprint` is hashed via SHA-256 and stored as `deviceHash`; + * the raw fingerprint is never persisted or logged. Trusted-device status + * is captured at creation time for trust-aware auditing. */ async createSession( userId: string, @@ -108,8 +107,8 @@ 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 deviceHash = this.enforceDeviceFingerprintPolicy(deviceFingerprint); if (this.sessionPolicy.singleSessionMode) { await this.revokeAllUserSessions(userId); @@ -117,9 +116,9 @@ export class AuthSessionService { const activeSessions = await this.getActiveSessions(userId); if (activeSessions.length >= this.sessionPolicy.maxConcurrentSessions) { - const oldest = activeSessions.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - )[0]; + const oldest = activeSessions + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())[0]; if (oldest) { await this.revokeSession(oldest.sessionId); this.logger.warn( @@ -128,34 +127,45 @@ export class AuthSessionService { } } + const { accessToken, refreshToken } = await this.signTokenPair(userId, role, sessionId); + const isTrustedDevice = deviceHash + ? await this.isTrustedDevice(userId, deviceHash) + : undefined; + const session: Session & { lastUsedAt: Date } = { sessionId, userId, role, refreshTokenHash: this.hashToken(refreshToken), - refreshTokenThash: this.hashToken(refreshToken), createdAt: now, expiresAt, revoked: false, deviceHash, - isTrustedDevice: deviceHash - ? await this.isTrustedDevice(userId, deviceHash) - : undefined, + isTrustedDevice, lastUsedAt: now, }; await this.setSession(session); if (deviceHash) await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); - if (deviceHash && !(await this.isTrustedDevice(userId, deviceHash))) { + if (deviceHash && !isTrustedDevice) { 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.audit('login', userId, { session: sessionId, requestContext: { deviceHash } }); return this.buildTokensResponse(accessToken, refreshToken); } - async refreshTokens(rawRefreshToken: string): Promise { + /** + * Rotates a refresh token, returning a fresh token pair. + * + * BA-016: when the device fingerprint policy is enabled, a fingerprint must + * be presented and must hash to the same value bound to the session. A + * changed fingerprint revokes the session (rotation behavior) so a stolen + * token cannot be used from a different device. The rotated session keeps + * the device binding. + */ + async refreshTokens(rawRefreshToken: string, deviceFingerprint?: string): Promise { let payload: RefreshTokenPayload; try { payload = await this.jwtService.verifyAsync( @@ -172,7 +182,10 @@ 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') { + // ioredis returns "OK" on success and null when the NX key already + // exists. Store adapters without NX semantics (e.g. RedisService) + // resolve to void/undefined, which we treat as a successful claim. + if (claimed === null) { throw new UnauthorizedException({ error: 'SESSION_NOT_FOUND', message: 'Session has been revoked or does not exist', @@ -189,19 +202,30 @@ export class AuthSessionService { } if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); + // A replay indicates that the user's refresh-token family may be + // compromised, so revoke every remaining session for the user. + await this.revokeAllUserSessions(session.userId, 'token_reuse'); throw new UnauthorizedException({ error: 'TOKEN_REUSE_DETECTED', message: 'Refresh token has already been used; session revoked', }); } - 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)) { + if (this.sessionPolicy.requireDeviceFingerprint) { + const presentedHash = this.enforceDeviceFingerprintPolicy(deviceFingerprint); + if (session.deviceHash && presentedHash !== session.deviceHash) { + session.revoked = true; + await this.setSession(session); + await this.redis.del(claimKey); + throw new UnauthorizedException({ + error: 'DEVICE_MISMATCH', + message: 'Refresh token was presented from a different device; session revoked', + }); + } + } + + const now = new Date(); + if (this.isSessionExpired(session, now)) { session.revoked = true; await this.setSession(session); await this.redis.del(claimKey); @@ -211,25 +235,42 @@ 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', + }); + } + + // Rotate: revoke the old session before issuing the new token pair. session.revoked = true; await this.setSession(session); await this.redis.del(claimKey); - return this.createSession(session.userId, session.role); + + await this.audit('refresh', session.userId, { session: session.sessionId }); + return this.createSession(session.userId, session.role, deviceFingerprint); }); - if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - // Token reuse detected - revoke the whole session as a security measure. - session.revoked = true; - await this.setSession(session); + } + + /** + * Validates that a session is still active (exists, not revoked, not + * expired, not idle). Throws `UnauthorizedException` otherwise. Used by + * guards enforcing session lifetime independently of JWT verification. + */ + async validateSession(sessionId: string): Promise { + const session = await this.getSession(sessionId); + const now = new Date(); + + if (!session || session.revoked) { throw new UnauthorizedException({ - error: 'TOKEN_REUSE_DETECTED', - message: 'Refresh token has already been used; session revoked', + error: 'SESSION_NOT_FOUND', + message: 'Session is expired, revoked, or does not exist', }); } - 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); @@ -239,7 +280,6 @@ export class AuthSessionService { }); } - // Enforce idle timeout independently of token validity. if (this.isSessionIdle(session, now)) { session.revoked = true; await this.setSession(session); @@ -249,41 +289,38 @@ export class AuthSessionService { }); } - // Revoke the old session before issuing new tokens (rotation). - session.revoked = true; - await this.setSession(session); + return session; + } - await this.auditService.create({ action: 'refresh', actor: session.userId, outcome: 'SUCCESS', session: session.sessionId }); - return await this.createSession(session.userId, session.role); + /** + * Validates an active session (same checks as {@link validateSession}) and + * refreshes the last-activity timestamp, extending the idle window. + */ + async validateAndRefreshSession(sessionId: string): Promise { + const session = await this.validateSession(sessionId); + await this.touchSession(sessionId); + return session; } /** * 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 }); + await this.audit(reason, session.userId, { session: sessionId }); } } /** * Revokes all active sessions for a user (logout from all devices). - * Clears all associated refresh tokens and cached session data. + * Also clears the user's session-set so orphaned ids do not accumulate. */ 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) { const session = await this.getSession(sessionId); @@ -294,9 +331,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 } }); + await this.audit(reason, userId, { requestContext: { count } }); } + /** + * #360: Revoke all sessions when a security-sensitive user event occurs. + */ async onPasswordChanged(userId: string): Promise { await this.revokeAllUserSessions(userId, 'password_changed'); } @@ -311,32 +351,29 @@ export class AuthSessionService { async onAccountDeleted(userId: string): Promise { await this.revokeAllUserSessions(userId, 'account_deleted'); - await this.redis.del(`userSessions:${userId}`); 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, not idle) sessions for a + * user. Refresh-token material (raw or hashed) is never returned. */ - async getActiveSessions(userId: string): Promise[]> { - async getActiveSessions(userId: string): Promise[]> { - const sessionIds = await this.redis.smembers(`userSessions:${userId}`); + async getActiveSessions(userId: string): Promise[]> { + const sessionIds = await this.redis.smembers(this.userSessionsKey(userId)); const now = new Date(); - const result: Omit[] = []; + 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; - result.push(rest); + if ( + session && + session.userId === userId && + !session.revoked && + !this.isSessionExpired(session, now) && + !this.isSessionIdle(session, now) + ) { + const { refreshTokenHash, ...safeSession } = session; + void refreshTokenHash; + result.push(safeSession); } } return result; @@ -344,8 +381,12 @@ export class AuthSessionService { // ------------------------------------------------------------------------------------------- // Device binding & trusted device recognition - // -------------------------------------------------------------------------------------------- + // ------------------------------------------------------------------------------------------- + /** + * Hashes a fingerprint with SHA-256 before it is stored or compared. The + * raw fingerprint must never be logged or persisted. + */ hashDevice(fingerprint: string): string { return createHash('sha256').update(fingerprint).digest('hex'); } @@ -357,17 +398,12 @@ 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 } }); + await this.audit('add_trusted_device', userId, { 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}`); + await this.audit('remove_trusted_device', userId, { requestContext: { deviceHash } }); } async getTrustedDevices(userId: string): Promise { @@ -379,10 +415,32 @@ export class AuthSessionService { return { trusted: await this.isTrustedDevice(userId, deviceHash), deviceHash }; } + // ------------------------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------------------------- + + private async touchSession(sessionId: string): Promise { + const session = await this.getSession(sessionId); + if (session) { + session.lastUsedAt = new Date(); + await this.setSession(session); + } + } + private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } + private async audit( + action: string, + actor: string, + context: { session?: string; requestContext?: Record } = {}, + ): Promise { + if (this.auditService) { + await this.auditService.create({ action, actor, outcome: 'SUCCESS', ...context }); + } + } + private async withRefreshLock(sessionId: string, operation: () => Promise): Promise { const previous = this.refreshLocks.get(sessionId) ?? Promise.resolve(); let release!: () => void; @@ -411,29 +469,19 @@ export class AuthSessionService { return `userSessions:${userId}`; } - private async getSession(sessionId: string): Promise { + private async getSession(sessionId: string): Promise<(Session & { lastUsedAt?: Date }) | null> { const data = await this.redis.get(this.sessionKey(sessionId)); if (!data) return null; - const session = JSON.parse(data as string) as Session; - session.createdAt = new Date(session.createdAt); - session.expiresAt = new Date(session.expiresAt); - 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; + const session = JSON.parse(data) as Session & { lastUsedAt?: Date }; return { ...session, createdAt: new Date(session.createdAt), expiresAt: new Date(session.expiresAt), + ...(session.lastUsedAt ? { lastUsedAt: new Date(session.lastUsedAt) } : {}), }; } - private async setSession(session: Session): Promise { + private async setSession(session: Session & { lastUsedAt?: Date }): Promise { const ttlSeconds = Math.max( 1, Math.floor((session.expiresAt.getTime() - Date.now()) / 1000) + this.sessionPolicy.deliveryGracePeriod, @@ -445,12 +493,6 @@ export class AuthSessionService { 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, @@ -468,19 +510,6 @@ 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 }; } @@ -492,32 +521,14 @@ 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; + const expiryWithGrace = new Date(session.expiresAt).getTime() + this.sessionPolicy.deliveryGracePeriod * 1000; + return now.getTime() > 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/dto/login.dto.ts b/BackendAcademy/src/auth/dto/login.dto.ts index 0c1df3d68..ae59084de 100644 --- a/BackendAcademy/src/auth/dto/login.dto.ts +++ b/BackendAcademy/src/auth/dto/login.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; import { UserRole } from '../enums/user-role.enum'; /** @@ -20,4 +20,14 @@ export class LoginDto { @IsNotEmpty() @MinLength(6) password: string; + + /** + * BA-016: client-generated device fingerprint. Optional at the API layer; + * whether it is required to log in is controlled by the session policy + * (`SESSION_REQUIRE_DEVICE`). Passed to {@link AuthSessionService.createSession} + * unmodified only for hashing; the raw value is never stored or logged. + */ + @IsOptional() + @IsString() + deviceFingerprint?: string; } diff --git a/BackendAcademy/src/auth/dto/refresh-token.dto.ts b/BackendAcademy/src/auth/dto/refresh-token.dto.ts index e8bfdae6e..c5dd47b0d 100644 --- a/BackendAcademy/src/auth/dto/refresh-token.dto.ts +++ b/BackendAcademy/src/auth/dto/refresh-token.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; /** * Request body for POST /auth/session/refresh. @@ -7,4 +7,13 @@ export class RefreshTokenDto { @IsString() @IsNotEmpty() refreshToken: string; + + /** + * BA-016: device fingerprint presented at rotation. Required when + * `SESSION_REQUIRE_DEVICE` is enabled; otherwise optional. Only the SHA-256 + * hash is ever compared or stored. + */ + @IsOptional() + @IsString() + deviceFingerprint?: string; } diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts index c907caeb4..db1193f6b 100644 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts @@ -1,15 +1,30 @@ -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'; +/** + * Protects routes that require a valid learner JWT. + * + * Expects an `authorization: Bearer ` Header. + * The token payload must contain `role: "learner"` and a `sessionId`. + * The associated session must not be expired, revoked, or idle. + * + * On success, attaches `request.user` with the decoded payload. + */ @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,27 +32,51 @@ 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 }; + let payload: JwtPayload; try { - payload = await this.jwtService.verifyAsync(token); + payload = await this.jwtService.verifyAsync(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' }); + // Enforce session expiration independently of JWT verification. + const sessionId = (payload as JwtPayload & { sessionId?: string }).sessionId; + if (!sessionId) { + throw new UnauthorizedException({ + error: 'MISSING_SESSION', + message: 'Token does not contain a session identifier', + }); } - // Enforce session expiration independently of JWT verification. - await this.sessionService.validateSession(payload.sessionId); + try { + await this.authSessionService.validateSession(sessionId); + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException({ + error: 'INVALID_SESSION', + message: 'Session is expired, revoked, or inactive', + }); + } + // Attach decoded learner identity for downstream handlers (request as Request & { user: JwtPayload }).user = payload; return true; } @@ -46,4 +85,4 @@ export class JwtLearnerGuard implements CanActivate { 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..05904f17e 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,9 +48,9 @@ export class JwtTutorGuard implements CanActivate { }); } - if (payload.role !== UserRole.TUTIOR) { + if (payload.role !== UserRole.TUTOR) { throw new ForbiddenException({ - error: 'TUTIOR_ROLE_REQUIRED', + error: 'TUTOR_ROLE_REQUIRED', message: 'Only tutors are allowed to access this resource', }); } @@ -60,7 +60,8 @@ export class JwtTutorGuard implements CanActivate { // or idle beyond the allowed timeout. Session validation also // refreshes the last activity timestamp and removes/marks expired // sessions as required. - if (!payload.sessionId) { + const sessionId = (payload as JwtPayload & { sessionId?: string }).sessionId; + if (!sessionId) { throw new UnauthorizedException({ error: 'MISSING_SESSION', message: 'Token does not contain a session identifier', @@ -68,7 +69,7 @@ export class JwtTutorGuard implements CanActivate { } try { - await this.authSessionService.validateAndRefreshSession(payload.sessionId); + await this.authSessionService.validateAndRefreshSession(sessionId); } catch (error) { if (error instanceof UnauthorizedException) { throw error; diff --git a/BackendAcademy/src/auth/interfaces/session.interface.ts b/BackendAcademy/src/auth/interfaces/session.interface.ts index 8e9d0b236..6b68c8a6e 100644 --- a/BackendAcademy/src/auth/interfaces/session.interface.ts +++ b/BackendAcademy/src/auth/interfaces/session.interface.ts @@ -25,14 +25,24 @@ export interface Session { /** When the refresh token expires. */ expiresAt: Date; - /** Absolute maximum lifetime of the session, independent of JWT exp. */ - absoluteExpiresAt: Date; + /** + * Absolute maximum lifetime of the session, independent of JWT exp. + * BA-019 persistence migration also writes this field (optional for + * back-compat with records written before the migration). + */ + absoluteExpiresAt?: Date; - /** Timestamp after which the session is considered idle-expired if no activity. */ - idleExpiresAt: Date; + /** + * Timestamp after which the session is considered idle-expired if no + * activity. BA-019 persistence migration writes this field. + */ + idleExpiresAt?: Date; - /** Grace period in seconds allowed for token delivery after expiry (clock skew buffer). */ - deliveryGraceSeconds: number; + /** + * Grace period in seconds allowed for token delivery after expiry (clock + * skew buffer). BA-019 persistence migration writes this field. + */ + deliveryGraceSeconds?: number; /** Flag set to true once the session is revoked (logout / rotation). */ revoked: boolean; @@ -46,3 +56,18 @@ export interface Session { /** Whether the device has been previously trusted by this user. */ isTrustedDevice?: boolean; } + +/** Payload embedded inside the refresh token JWT. */ +export interface RefreshTokenPayload { + sub: string; + role: UserRole; + sessionId: string; +} + +/** Shape returned by session issuance and rotation. */ +export interface AuthTokensResponse { + accessToken: string; + refreshToken: string; + tokenType: 'Bearer'; + expiresIn: number; +} diff --git a/BackendAcademy/src/redis/redis.module.ts b/BackendAcademy/src/redis/redis.module.ts index c34bc96dc..e71435be2 100644 --- a/BackendAcademy/src/redis/redis.module.ts +++ b/BackendAcademy/src/redis/redis.module.ts @@ -1,7 +1,17 @@ import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import * as Redis from 'ioredis'; import { RedisService } from './redis.service'; +function parsePort(value: string | undefined, defaultValue: number): number { + const raw = value ?? defaultValue.toString(); + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid REDIS_PORT: ${raw}`); + } + return port; +} + @Global() @Module({ imports: [ConfigModule], @@ -10,12 +20,26 @@ import { RedisService } from './redis.service'; provide: 'REDIS_OPTIONS', useFactory: (config: ConfigService) => ({ host: config.get('REDIS_HOST', 'localhost'), - port: config.get('REDIS_PORT', 6379), + port: parsePort(config.get('REDIS_PORT'), 6379), password: config.get('REDIS_PASSWORD'), + db: config.get('REDIS_DB', 0), }), inject: [ConfigService], }, RedisService, + { + provide: 'REDIS_CLIENT', + useFactory: (config: ConfigService) => + new Redis.default({ + host: config.get('REDIS_HOST', 'localhost'), + port: parsePort(config.get('REDIS_PORT'), 6379), + password: config.get('REDIS_PASSWORD'), + db: config.get('REDIS_DB', 0), + maxRetriesPerRequest: 2, + lazyConnect: true, + }), + inject: [ConfigService], + }, { provide: 'SessionStore', inject: [RedisService], @@ -33,37 +57,6 @@ import { RedisService } from './redis.service'; }), }, ], - 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); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error(`Invalid REDIS_PORT: ${raw}`); - } - return port; -} - -@Global() -@Module({ - imports: [ConfigModule], - providers: [ - { - provide: 'REDIS_OPTIONS', - useFactory: (config: ConfigService) => ({ - host: config.get('REDIS_HOST', 'localhost'), - port: parsePort(config.get('REDIS_PORT'), 6379), - password: config.get('REDIS_PASSWORD'), - }), - inject: [ConfigService], - }, - RedisService, - ], - exports: ['REDIS_OPTIONS', RedisService], + exports: ['REDIS_OPTIONS', RedisService, 'REDIS_CLIENT', 'SessionStore'], }) -export class RedisModule {} +export class RedisModule {} \ No newline at end of file