diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index bdc6abc..58a4134 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; +import { RefreshDto } from './dto/refresh.dto'; import { AUTH_LOGIN_THROTTLE, AUTH_THROTTLE, @@ -18,7 +19,7 @@ export class AuthController { @Throttle(AUTH_LOGIN_THROTTLE) @ApiOperation({ summary: 'Login with Stellar wallet signature' }) @ApiBody({ type: LoginDto }) - @ApiResponse({ status: 201, description: 'Login successful, returns access token' }) + @ApiResponse({ status: 201, description: 'Login successful, returns access and refresh tokens' }) @ApiResponse({ status: 401, description: 'Invalid signature or wallet not found' }) @ApiResponse({ status: 400, description: 'Invalid request body' }) @ApiResponse({ status: 429, description: 'Too many login attempts' }) @@ -26,6 +27,16 @@ export class AuthController { return this.authService.login(loginDto); } + @Post('refresh') + @ApiOperation({ summary: 'Rotate refresh token and issue a new access token' }) + @ApiBody({ type: RefreshDto }) + @ApiResponse({ status: 200, description: 'Refresh successful, returns new access and refresh tokens' }) + @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) + @ApiResponse({ status: 400, description: 'Invalid request body' }) + async refresh(@Body() refreshDto: RefreshDto) { + return this.authService.refresh(refreshDto.refreshToken); + } + @Get() @ApiOperation({ summary: 'Get auth module status' }) @ApiResponse({ status: 200, description: 'Module status' }) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index d2a638a..115aedb 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -1,3 +1,26 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { createMockRepository, MockRepository } from '../../common/mocks/repository.mock'; +import { User } from '../users/user.entity'; +import { Wallet } from '../wallet/wallet.entity'; + +describe('AuthService', () => { + let authService: AuthService; + let mockWalletRepo: MockRepository; + let mockUserRepo: MockRepository; + let jwtService: JwtService; + + beforeEach(() => { + mockWalletRepo = createMockRepository(); + mockUserRepo = createMockRepository(); + jwtService = { sign: jest.fn().mockReturnValue('mock-access-token') } as unknown as JwtService; + + authService = new AuthService( + mockWalletRepo as unknown as any, + mockUserRepo as unknown as any, + jwtService, + ); import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { JwtService } from '@nestjs/jwt'; @@ -65,6 +88,103 @@ describe('AuthService - Account Lockout', () => { jest.clearAllMocks(); }); + it('should login and issue access and refresh tokens', async () => { + const testUser: User = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + name: 'Test User', + role: 'user', + isSuspended: false, + suspensionReason: null, + refreshToken: null, + refreshTokenExpiry: null, + createdAt: new Date(), + updatedAt: new Date(), + } as User; + + const testWallet: Wallet = { + id: 'wallet-id', + publicKey: 'GTESTPUBLICKEY', + userId: testUser.id, + createdAt: new Date(), + } as Wallet; + + mockWalletRepo.findOne.mockResolvedValue(testWallet); + mockUserRepo.findOne.mockResolvedValue(testUser); + mockUserRepo.update.mockResolvedValue(testUser); + + const result = await authService.login({ + publicKey: testWallet.publicKey, + signature: 'dummy-signature', + message: 'dummy-message', + }); + + expect(result.accessToken).toBe('mock-access-token'); + expect(result.refreshToken).toBeDefined(); + expect(result.publicKey).toBe(testWallet.publicKey); + expect(result.userId).toBe(testUser.id); + expect(mockUserRepo.update).toHaveBeenCalledWith(testUser.id, expect.objectContaining({ + refreshToken: expect.any(String), + refreshTokenExpiry: expect.any(Date), + })); + }); + + it('should refresh and rotate the refresh token', async () => { + let currentRefreshToken = 'old-refresh-token'; + const testUser: User = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + name: 'Test User', + role: 'user', + isSuspended: false, + suspensionReason: null, + refreshToken: currentRefreshToken, + refreshTokenExpiry: new Date(Date.now() + 10000), + createdAt: new Date(), + updatedAt: new Date(), + } as User; + + const testWallet: Wallet = { + id: 'wallet-id', + publicKey: 'GTESTPUBLICKEY', + userId: testUser.id, + createdAt: new Date(), + } as Wallet; + + mockUserRepo.findOne.mockImplementation(async (criteria: { where?: { refreshToken?: string } }) => { + if (criteria.where?.refreshToken === currentRefreshToken) { + return testUser; + } + return null; + }); + + mockWalletRepo.findOne.mockResolvedValue(testWallet); + mockUserRepo.update.mockImplementation(async (_id: string, update: Partial) => { + const refreshToken = update.refreshToken as string; + const refreshTokenExpiry = update.refreshTokenExpiry as Date; + currentRefreshToken = refreshToken; + testUser.refreshToken = refreshToken; + testUser.refreshTokenExpiry = refreshTokenExpiry; + return testUser; + }); + + const firstResult = await authService.refresh(currentRefreshToken); + + expect(firstResult.accessToken).toBe('mock-access-token'); + expect(firstResult.refreshToken).toBeDefined(); + expect(firstResult.refreshToken).not.toBe('old-refresh-token'); + expect(mockUserRepo.update).toHaveBeenCalledWith(testUser.id, expect.objectContaining({ + refreshToken: firstResult.refreshToken, + refreshTokenExpiry: expect.any(Date), + })); + + await expect(authService.refresh('old-refresh-token')).rejects.toThrow(UnauthorizedException); + }); + + it('should reject an invalid refresh token', async () => { + mockUserRepo.findOne.mockResolvedValue(null); + + await expect(authService.refresh('invalid-token')).rejects.toThrow(UnauthorizedException); describe('Account Lockout', () => { it('should lock account after 5 failed attempts', async () => { mockWalletRepository.findOne.mockResolvedValue(mockWallet); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 1dd0c3e..5b4c8ea 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -6,6 +6,14 @@ import { Wallet } from '../wallet/wallet.entity'; import { User } from '../users/user.entity'; import { Keypair } from '@stellar/stellar-sdk'; import { LoginDto } from './dto/login.dto'; +import { randomBytes } from 'crypto'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + publicKey: string; + userId: string; +} const MAX_FAILED_ATTEMPTS = 5; const LOCK_DURATION_MINUTES = 15; @@ -111,17 +119,70 @@ export class AuthService { this.logger.log(`Successful login for user: ${user.id}`); - // Generate JWT token + const user = await this.userRepository.findOne({ where: { id: wallet.userId } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + const payload = { publicKey: wallet.publicKey, sub: wallet.userId }; - const accessToken = this.jwtService.sign(payload); + const accessToken = this.generateAccessToken(payload); + const { token: refreshToken, expiry: refreshTokenExpiry } = this.createRefreshToken(); + + await this.userRepository.update(user.id, { + refreshToken, + refreshTokenExpiry, + }); return { accessToken, + refreshToken, publicKey: wallet.publicKey, userId: wallet.userId, }; } + async refresh(refreshToken: string) { + if (!refreshToken) { + throw new UnauthorizedException('Refresh token is required'); + } + + const user = await this.userRepository.findOne({ where: { refreshToken } }); + if (!user || !user.refreshTokenExpiry || user.refreshTokenExpiry.getTime() <= Date.now()) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + const wallet = await this.walletRepository.findOne({ where: { userId: user.id } }); + if (!wallet) { + throw new UnauthorizedException('Wallet not found'); + } + + const payload = { publicKey: wallet.publicKey, sub: user.id }; + const accessToken = this.generateAccessToken(payload); + const { token: newRefreshToken, expiry: refreshTokenExpiry } = this.createRefreshToken(); + + await this.userRepository.update(user.id, { + refreshToken: newRefreshToken, + refreshTokenExpiry, + }); + + return { + accessToken, + refreshToken: newRefreshToken, + publicKey: wallet.publicKey, + userId: user.id, + }; + } + + private generateAccessToken(payload: { publicKey: string; sub: string }): string { + return this.jwtService.sign(payload); + } + + private createRefreshToken(): { token: string; expiry: Date } { + const token = randomBytes(48).toString('hex'); + const expiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + return { token, expiry }; + } + private async verifySignature( publicKey: string, signature: string, diff --git a/src/modules/auth/dto/refresh.dto.ts b/src/modules/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..6a09b15 --- /dev/null +++ b/src/modules/auth/dto/refresh.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class RefreshDto { + @ApiProperty({ description: 'Refresh token' }) + @IsString() + @IsNotEmpty() + refreshToken: string; +} diff --git a/src/modules/users/user.entity.ts b/src/modules/users/user.entity.ts index 869beea..ac636bc 100644 --- a/src/modules/users/user.entity.ts +++ b/src/modules/users/user.entity.ts @@ -25,6 +25,11 @@ export class User { @Column({ type: 'varchar', length: 500, nullable: true }) suspensionReason: string | null; + @Column({ type: 'varchar', length: 500, nullable: true }) + refreshToken: string | null; + + @Column({ type: 'datetime', nullable: true }) + refreshTokenExpiry: Date | null; @Column({ type: 'int', default: 0 }) failedLoginAttempts: number;