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
161 changes: 161 additions & 0 deletions src/modules/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { Wallet } from '../wallet/wallet.entity';
import { User } from '../users/user.entity';
import { HttpException, HttpStatus } from '@nestjs/common';

describe('AuthService - Account Lockout', () => {
let service: AuthService;

const mockUserId = '123e4567-e89b-12d3-a456-426614174000';
const mockPublicKey = 'GABCDEF1234567890';

const mockUser = {
id: mockUserId,
email: 'test@example.com',
name: 'Test User',
failedLoginAttempts: 0,
lockedUntil: null,
};

const mockWallet = {
id: '123e4567-e89b-12d3-a456-426614174001',
publicKey: mockPublicKey,
userId: mockUserId,
};

const mockUserRepository = {
findOne: jest.fn(),
save: jest.fn().mockResolvedValue(mockUser),
};

const mockWalletRepository = {
findOne: jest.fn(),
};

const mockJwtService = {
sign: jest.fn().mockReturnValue('mock-token'),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
{
provide: getRepositoryToken(Wallet),
useValue: mockWalletRepository,
},
{
provide: JwtService,
useValue: mockJwtService,
},
],
}).compile();

service = module.get<AuthService>(AuthService);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('Account Lockout', () => {
it('should lock account after 5 failed attempts', async () => {
mockWalletRepository.findOne.mockResolvedValue(mockWallet);
mockUserRepository.findOne.mockResolvedValue({
...mockUser,
failedLoginAttempts: 0,
lockedUntil: null,
});

jest.spyOn(service as any, 'verifySignature').mockResolvedValue(false);

for (let i = 0; i < 4; i++) {
try {
await service.login({
publicKey: mockPublicKey,
signature: 'invalid-signature',
message: 'test-message',
});
} catch {
// Expected to fail
}
}

mockUserRepository.findOne.mockResolvedValue({
...mockUser,
failedLoginAttempts: 4,
lockedUntil: null,
});

try {
await service.login({
publicKey: mockPublicKey,
signature: 'invalid-signature',
message: 'test-message',
});
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
if (error instanceof HttpException) {
expect(error.getStatus()).toBe(HttpStatus.LOCKED);
}
}
});

it('should return 423 with unlock time when account is locked', async () => {
const lockUntil = new Date(Date.now() + 15 * 60 * 1000);

mockWalletRepository.findOne.mockResolvedValue(mockWallet);
mockUserRepository.findOne.mockResolvedValue({
...mockUser,
lockedUntil: lockUntil,
failedLoginAttempts: 5,
});

try {
await service.login({
publicKey: mockPublicKey,
signature: 'invalid-signature',
message: 'test-message',
});
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
if (error instanceof HttpException) {
expect(error.getStatus()).toBe(HttpStatus.LOCKED);
}
}
});

it('should reset failed attempts on successful login', async () => {
mockWalletRepository.findOne.mockResolvedValue(mockWallet);

const userWithAttempts = {
...mockUser,
failedLoginAttempts: 3,
lockedUntil: null,
};
mockUserRepository.findOne.mockResolvedValue(userWithAttempts);

jest.spyOn(service as any, 'verifySignature').mockResolvedValue(true);

const result = await service.login({
publicKey: mockPublicKey,
signature: 'valid-signature',
message: 'test-message',
});

expect(result).toHaveProperty('accessToken');
expect(result).toHaveProperty('publicKey', mockPublicKey);
expect(mockUserRepository.save).toHaveBeenCalled();
const savedUser = mockUserRepository.save.mock.calls[0][0];

Check warning on line 156 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unsafe member access [0] on an `any` value

Check warning on line 156 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unsafe assignment of an `any` value

Check warning on line 156 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access [0] on an `any` value

Check warning on line 156 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
expect(savedUser.failedLoginAttempts).toBe(0);

Check warning on line 157 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unsafe member access .failedLoginAttempts on an `any` value

Check warning on line 157 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .failedLoginAttempts on an `any` value
expect(savedUser.lockedUntil).toBeNull();

Check warning on line 158 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unsafe member access .lockedUntil on an `any` value

Check warning on line 158 in src/modules/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .lockedUntil on an `any` value
});
});
});
108 changes: 91 additions & 17 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,115 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable, UnauthorizedException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Wallet } from '../wallet/wallet.entity';
import { User } from '../users/user.entity';
import { Keypair } from '@stellar/stellar-sdk';
import { LoginDto } from './dto/login.dto';

const MAX_FAILED_ATTEMPTS = 5;
const LOCK_DURATION_MINUTES = 15;

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);

constructor(
@InjectRepository(Wallet)
private walletRepository: Repository<Wallet>,
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
) {}

async login(loginDto: LoginDto) {
const { publicKey, signature, message } = loginDto;

this.logger.debug(`Login attempt for public key: ${publicKey}`);

// Find wallet
const wallet = await this.walletRepository.findOne({
where: { publicKey },
});

if (!wallet) {
this.logger.warn(`Wallet not found for public key: ${publicKey}`);
throw new UnauthorizedException('Wallet not found');
}

// Find user by userId from wallet
const user = await this.userRepository.findOne({
where: { id: wallet.userId },
});

if (!user) {
this.logger.warn(`User not found for wallet: ${publicKey}`);
throw new UnauthorizedException('User not found');
}

// Check if account is locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
const remainingSeconds = Math.floor((user.lockedUntil.getTime() - Date.now()) / 1000);
const unlockTime = user.lockedUntil.toISOString();

this.logger.warn(
`Locked account login attempt: ${publicKey}, remaining: ${remainingSeconds}s`,
);

throw new HttpException(
{
statusCode: HttpStatus.LOCKED,
message: 'Account is temporarily locked due to multiple failed login attempts.',
unlockTime,
remainingSeconds,
},
HttpStatus.LOCKED,
);
}

// Verify the signature
const isValid = await this.verifySignature(publicKey, signature, message);

if (!isValid) {
// Increment failed attempts
user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1;

let wasLocked = false;
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
const lockUntil = new Date();
lockUntil.setMinutes(lockUntil.getMinutes() + LOCK_DURATION_MINUTES);
user.lockedUntil = lockUntil;
wasLocked = true;
}

await this.userRepository.save(user);

this.logger.warn(
`Failed login attempt for ${publicKey}, attempts: ${user.failedLoginAttempts}`,
);

if (wasLocked) {
const unlockTime = user.lockedUntil!.toISOString();
throw new HttpException(
{
statusCode: HttpStatus.LOCKED,
message: 'Account locked due to multiple failed login attempts.',
unlockTime,
remainingSeconds: LOCK_DURATION_MINUTES * 60,
},
HttpStatus.LOCKED,
);
}

throw new UnauthorizedException('Invalid signature');
}

// Find or create wallet
const wallet = await this.walletRepository.findOne({ where: { publicKey } });

if (!wallet) {
throw new UnauthorizedException('Wallet not found');
}
// Successful login - reset failed attempts
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await this.userRepository.save(user);

this.logger.log(`Successful login for user: ${user.id}`);

// Generate JWT token
const payload = { publicKey: wallet.publicKey, sub: wallet.userId };
Expand All @@ -47,24 +128,17 @@ export class AuthService {
message: string,
): Promise<boolean> {
try {
// Create a Keypair from the public key
const keypair = Keypair.fromPublicKey(publicKey);

// Convert message to buffer
const messageBuffer = Buffer.from(message, 'utf8');

// Convert signature from base64 to buffer
const signatureBuffer = Buffer.from(signature, 'base64');

// Verify the signature
return keypair.verify(messageBuffer, signatureBuffer);
} catch {
// Signature verification failed - return false without logging
} catch (error) {
this.logger.error(`Signature verification failed: ${error}`);
return false;
}
}

getStatus() {
return { module: 'Auth', status: 'Working' };
}
}
}
43 changes: 43 additions & 0 deletions src/modules/users/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,52 @@ export class User {
@Column({ type: 'varchar', length: 500, nullable: true })
suspensionReason: string | null;

@Column({ type: 'int', default: 0 })
failedLoginAttempts: number;

@Column({ type: 'datetime', nullable: true })
lockedUntil: Date | null;

@CreateDateColumn({ type: 'datetime' })
createdAt: Date;

@UpdateDateColumn({ type: 'datetime' })
updatedAt: Date;

/**
* Check if the account is currently locked
*/
isLocked(): boolean {
return this.lockedUntil !== null && this.lockedUntil > new Date();
}

/**
* Get remaining lock time in seconds
*/
getRemainingLockTimeSeconds(): number {
if (!this.isLocked()) return 0;
return Math.floor((this.lockedUntil!.getTime() - Date.now()) / 1000);
}

/**
* Reset failed login attempts
*/
resetFailedAttempts(): void {
this.failedLoginAttempts = 0;
this.lockedUntil = null;
}

/**
* Increment failed login attempts and lock if needed
*/
incrementFailedAttempts(maxAttempts: number): boolean {
this.failedLoginAttempts += 1;
if (this.failedLoginAttempts >= maxAttempts) {
const lockUntil = new Date();
lockUntil.setMinutes(lockUntil.getMinutes() + 15);
this.lockedUntil = lockUntil;
return true;
}
return false;
}
}
Loading