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
219 changes: 209 additions & 10 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/nodemailer": "^7.0.10",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.6",
"@willsoto/nestjs-prometheus": "^6.0.2",
"amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.3",
Expand All @@ -63,11 +64,13 @@
"nest-winston": "^1.9.0",
"nestjs-i18n": "^10.6.0",
"nodemailer": "^8.0.1",
"otplib": "^13.4.0",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.0",
"prom-client": "^15.1.3",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
Expand Down
128 changes: 127 additions & 1 deletion src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Req } from "@nestjs/common"
import { Controller, Post, HttpCode, HttpStatus, UseGuards, Get, Req, Body } from "@nestjs/common"
import type { AuthService } from "./auth.service"
import { RegisterUserDto } from "./dto/register-user.dto"
import { LoginUserDto } from "./dto/login-user.dto"
Expand All @@ -7,12 +7,14 @@ import { ResetPasswordDto } from "./dto/reset-password.dto"
import { VerifyEmailDto } from "./dto/verify-email.dto"
import { JwtAuthGuard } from "./guards/jwt-auth.guard"
import { RefreshJwtAuthGuard } from "./guards/refresh-jwt-auth.guard"
import { MfaPendingAuthGuard } from "./guards/mfa-pending-auth.guard"
import type { RequestWithUser } from "./interfaces/request-with-user.interface"
import { Roles } from "./decorators/roles.decorator"
import { RolesGuard } from "./guards/roles.guard"
import { UserRole } from "./constants"
import { AuthGuard } from "@nestjs/passport"
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from "@nestjs/swagger"
import { VerifyTwoFactorDto, ChallengeTwoFactorDto, DisableTwoFactorDto } from "./dto/two-factor.dto"

@ApiTags("Authentication")
@Controller("auth")
Expand Down Expand Up @@ -213,4 +215,128 @@ export class AuthController {
...tokens,
}
}

// Two-Factor Authentication Endpoints
@Post("2fa/setup")
@ApiOperation({ summary: "Setup 2FA - generate TOTP secret and QR code" })
@ApiBearerAuth()
@ApiResponse({
status: 201,
description: "2FA secret generated. Scan QR code with authenticator app.",
schema: {
example: {
secret: "JBSWY3DPEHPK3PXP",
otpauthUrl: "otpauth://totp/Quest%20Service:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Quest%20Service",
qrCodeDataUri: "data:image/png;base64,...",
},
},
})
@ApiResponse({ status: 400, description: "2FA already enabled." })
@ApiResponse({ status: 401, description: "Unauthorized." })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED)
async setupTwoFactor(@Req() req: RequestWithUser) {
const result = await this.authService.generateTwoFactorSecret(req.user.id)
return result
}

@Post("2fa/verify")
@ApiOperation({ summary: "Verify 2FA setup with TOTP code" })
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: "2FA enabled successfully. Backup codes returned.",
schema: {
example: {
message: "2FA enabled successfully",
backupCodes: ["ABCD1234", "EFGH5678", "IJKL9012", "MNOP3456", "QRST7890", "UVWX1234", "YZAB5678", "CDEF9012"],
},
},
})
@ApiResponse({ status: 400, description: "Invalid TOTP code or 2FA already enabled." })
@ApiResponse({ status: 401, description: "Unauthorized." })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyTwoFactor(@Req() req: RequestWithUser, @Body() verifyDto: VerifyTwoFactorDto) {
const result = await this.authService.verifyTwoFactorSetup(req.user.id, verifyDto.code)
return result
}

@Post("2fa/challenge")
@ApiOperation({ summary: "Challenge endpoint to exchange mfa_pending token for full JWT tokens" })
@ApiResponse({
status: 200,
description: "2FA verified, full JWT tokens issued.",
schema: { example: { accessToken: "...", refreshToken: "..." } },
})
@ApiResponse({ status: 401, description: "Invalid MFA pending token or 2FA code." })
@ApiBody({
schema: {
type: "object",
properties: {
mfaPendingToken: { type: "string", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." },
code: { type: "string", example: "123456" },
},
required: ["mfaPendingToken", "code"],
},
})
@HttpCode(HttpStatus.OK)
async challengeTwoFactor(@Body() challengeDto: ChallengeTwoFactorDto & { mfaPendingToken: string }) {
const result = await this.authService.challengeTwoFactor(challengeDto.mfaPendingToken, challengeDto.code)
return result
}

@Post("2fa/disable")
@ApiOperation({ summary: "Disable 2FA (requires current TOTP code + password)" })
@ApiBearerAuth()
@ApiResponse({ status: 200, description: "2FA disabled successfully." })
@ApiResponse({ status: 400, description: "Invalid TOTP code or 2FA not enabled." })
@ApiResponse({ status: 401, description: "Unauthorized (invalid password)." })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async disableTwoFactor(@Req() req: RequestWithUser, @Body() disableDto: DisableTwoFactorDto) {
const result = await this.authService.disableTwoFactor(req.user.id, disableDto.code, disableDto.password)
return result
}

@Get("2fa/status")
@ApiOperation({ summary: "Get 2FA status for authenticated user" })
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: "2FA status.",
schema: { example: { isTwoFactorEnabled: true } },
})
@ApiResponse({ status: 401, description: "Unauthorized." })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async getTwoFactorStatus(@Req() req: RequestWithUser) {
const result = await this.authService.getTwoFactorStatus(req.user.id)
return result
}

@Post("2fa/backup-codes/regenerate")
@ApiOperation({ summary: "Regenerate backup codes (invalidates old ones)" })
@ApiBearerAuth()
@ApiResponse({
status: 201,
description: "Backup codes regenerated.",
schema: {
example: {
message: "Backup codes regenerated",
backupCodes: ["ABCD1234", "EFGH5678", "IJKL9012", "MNOP3456", "QRST7890", "UVWX1234", "YZAB5678", "CDEF9012"],
},
},
})
@ApiResponse({ status: 400, description: "2FA not enabled." })
@ApiResponse({ status: 401, description: "Unauthorized." })
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.CREATED)
async regenerateBackupCodes(@Req() req: RequestWithUser) {
const backupCodes = await this.authService.regenerateBackupCodes(req.user.id)
return {
message: "Backup codes regenerated",
backupCodes,
}
}
}
6 changes: 4 additions & 2 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@ import { AuthController } from "./auth.controller"
import { User } from "./entities/user.entity"
import { Role } from "./entities/role.entity"
import { RefreshToken } from "./entities/refresh-token.entity"
import { TwoFactorBackupCode } from "./entities/two-factor-backup-code.entity"
import { JwtStrategy } from "./strategies/jwt.strategy"
import { RefreshJwtStrategy } from "./strategies/refresh-jwt.strategy"
import { GoogleStrategy } from "./strategies/google.strategy"
import { jwtConstants } from "./constants"
import { MfaPendingAuthGuard } from "./guards/mfa-pending-auth.guard"

@Module({
imports: [
TypeOrmModule.forFeature([User, Role, RefreshToken]),
TypeOrmModule.forFeature([User, Role, RefreshToken, TwoFactorBackupCode]),
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: jwtConstants.accessExpiresIn as `${number}m` },
}),
],
providers: [AuthService, JwtStrategy, RefreshJwtStrategy, GoogleStrategy],
providers: [AuthService, JwtStrategy, RefreshJwtStrategy, GoogleStrategy, MfaPendingAuthGuard],
controllers: [AuthController],
exports: [AuthService], // Export AuthService if other modules need to use it
})
Expand Down
125 changes: 124 additions & 1 deletion src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getRepositoryToken } from "@nestjs/typeorm"
import { User } from "./entities/user.entity"
import { Role } from "./entities/role.entity"
import { RefreshToken } from "./entities/refresh-token.entity"
import { TwoFactorBackupCode } from "./entities/two-factor-backup-code.entity"
import { JwtService } from "@nestjs/jwt"
import * as bcrypt from "bcrypt"
import type { Repository } from "typeorm"
Expand Down Expand Up @@ -32,6 +33,13 @@ const mockRefreshTokenRepository = () => ({
update: jest.fn(),
})

const mockBackupCodeRepository = () => ({
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
})

// Mock JwtService
const mockJwtService = () => ({
sign: jest.fn(() => "mockAccessToken"),
Expand All @@ -43,6 +51,7 @@ describe("AuthService", () => {
let userRepository: Repository<User>
let roleRepository: Repository<Role>
let refreshTokenRepository: Repository<RefreshToken>
let backupCodeRepository: Repository<TwoFactorBackupCode>
let jwtService: JwtService

beforeEach(async () => {
Expand All @@ -52,6 +61,7 @@ describe("AuthService", () => {
{ provide: getRepositoryToken(User), useFactory: mockUserRepository },
{ provide: getRepositoryToken(Role), useFactory: mockRoleRepository },
{ provide: getRepositoryToken(RefreshToken), useFactory: mockRefreshTokenRepository },
{ provide: getRepositoryToken(TwoFactorBackupCode), useFactory: mockBackupCodeRepository },
{ provide: JwtService, useFactory: mockJwtService },
],
}).compile()
Expand All @@ -60,6 +70,7 @@ describe("AuthService", () => {
userRepository = module.get<Repository<User>>(getRepositoryToken(User))
roleRepository = module.get<Repository<Role>>(getRepositoryToken(Role))
refreshTokenRepository = module.get<Repository<RefreshToken>>(getRepositoryToken(RefreshToken))
backupCodeRepository = module.get<Repository<TwoFactorBackupCode>>(getRepositoryToken(TwoFactorBackupCode))
jwtService = module.get<JwtService>(JwtService)

// Mock bcrypt.hash and bcrypt.compare
Expand Down Expand Up @@ -146,7 +157,7 @@ describe("AuthService", () => {
} as User

jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
jest.spyOn(bcrypt, "compare").mockResolvedValue(true)
jest.spyOn(bcrypt, "compare").mockImplementation(() => Promise.resolve(true))

await expect(service.login(loginDto)).rejects.toThrow(UnauthorizedException)
expect(userRepository.findOne).toHaveBeenCalledWith(
Expand Down Expand Up @@ -452,4 +463,116 @@ describe("AuthService", () => {
)
})
})

describe("Two-Factor Authentication", () => {
describe("generateTwoFactorSecret", () => {
it("should generate TOTP secret and QR code for user", async () => {
const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: false } as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
jest.spyOn(userRepository, "save").mockResolvedValue(mockUser)

const result = await service.generateTwoFactorSecret("uuid-1")
expect(result).toHaveProperty("secret")
expect(result).toHaveProperty("otpauthUrl")
expect(result).toHaveProperty("qrCodeDataUri")
})

it("should throw error if user not found", async () => {
jest.spyOn(userRepository, "findOne").mockResolvedValue(null)
await expect(service.generateTwoFactorSecret("invalid-id")).rejects.toThrow(UnauthorizedException)
})

it("should throw error if 2FA already enabled", async () => {
const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: true } as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
await expect(service.generateTwoFactorSecret("uuid-1")).rejects.toThrow(BadRequestException)
})
})

describe("verifyTwoFactorSetup", () => {
it("should enable 2FA and return backup codes on valid code", async () => {
const mockUser = {
id: "uuid-1",
email: "test@example.com",
twoFactorSecret: "TESTSECRET",
isTwoFactorEnabled: false,
} as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
jest.spyOn(userRepository, "save").mockResolvedValue({ ...mockUser, isTwoFactorEnabled: true })
jest.spyOn(backupCodeRepository, "create").mockReturnValue({} as TwoFactorBackupCode)
jest.spyOn(backupCodeRepository, "save").mockResolvedValue({} as TwoFactorBackupCode)

const result = await service.verifyTwoFactorSetup("uuid-1", "123456")
expect(result.message).toBe("2FA enabled successfully")
expect(result.backupCodes).toHaveLength(8)
})

it("should throw error if secret not generated", async () => {
const mockUser = { id: "uuid-1", email: "test@example.com", twoFactorSecret: null } as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
await expect(service.verifyTwoFactorSetup("uuid-1", "123456")).rejects.toThrow(BadRequestException)
})
})

describe("disableTwoFactor", () => {
it("should disable 2FA with valid code and password", async () => {
const mockUser = {
id: "uuid-1",
email: "test@example.com",
password: "hashedPassword",
twoFactorSecret: "TESTSECRET",
isTwoFactorEnabled: true,
} as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
jest.spyOn(userRepository, "save").mockResolvedValue({ ...mockUser, isTwoFactorEnabled: false, twoFactorSecret: null })
jest.spyOn(bcrypt, "compare").mockImplementation(() => Promise.resolve(true))
jest.spyOn(backupCodeRepository, "update").mockResolvedValue({ affected: 8 } as any)

const result = await service.disableTwoFactor("uuid-1", "123456", "password123")
expect(result.message).toBe("2FA disabled successfully")
})

it("should throw error if 2FA not enabled", async () => {
const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: false } as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
await expect(service.disableTwoFactor("uuid-1", "123456", "password123")).rejects.toThrow(BadRequestException)
})
})

describe("challengeTwoFactor", () => {
it("should exchange mfa_pending token for full tokens", async () => {
const mockPayload = { sub: "uuid-1", email: "test@example.com", isMfaPending: true }
const mockUser = { id: "uuid-1", email: "test@example.com", role: { name: UserRole.USER } } as User

jest.spyOn(jwtService, "verifyAsync").mockResolvedValue(mockPayload)
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)
jest.spyOn(refreshTokenRepository, "create").mockReturnValue({} as RefreshToken)
jest.spyOn(refreshTokenRepository, "save").mockResolvedValue({} as RefreshToken)

const result = await service.challengeTwoFactor("mfa-pending-token", "123456")
expect(result).toHaveProperty("accessToken")
expect(result).toHaveProperty("refreshToken")
})

it("should throw error if mfa_pending token is invalid", async () => {
jest.spyOn(jwtService, "verifyAsync").mockRejectedValue(new Error("Invalid token"))
await expect(service.challengeTwoFactor("invalid-token", "123456")).rejects.toThrow()
})
})

describe("getTwoFactorStatus", () => {
it("should return 2FA status", async () => {
const mockUser = { id: "uuid-1", email: "test@example.com", isTwoFactorEnabled: true } as User
jest.spyOn(userRepository, "findOne").mockResolvedValue(mockUser)

const result = await service.getTwoFactorStatus("uuid-1")
expect(result.isTwoFactorEnabled).toBe(true)
})

it("should throw error if user not found", async () => {
jest.spyOn(userRepository, "findOne").mockResolvedValue(null)
await expect(service.getTwoFactorStatus("invalid-id")).rejects.toThrow(UnauthorizedException)
})
})
})
})
Loading
Loading