Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,14 +19,24 @@ 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' })
async login(@Body() loginDto: LoginDto) {
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' })
Expand Down
120 changes: 120 additions & 0 deletions src/modules/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Wallet>;
let mockUserRepo: MockRepository<User>;
let jwtService: JwtService;

beforeEach(() => {
mockWalletRepo = createMockRepository<Wallet>();
mockUserRepo = createMockRepository<User>();
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';
Expand Down Expand Up @@ -65,6 +88,103 @@
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<User>) => {
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);
Expand Down Expand Up @@ -159,3 +279,3 @@
});
});
});
65 changes: 63 additions & 2 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/modules/auth/dto/refresh.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/modules/users/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading