diff --git a/.env.example b/.env.example index 7250b80d..cf654541 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,9 @@ NODE_ENV=development # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-in-production -JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-in-production +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# Security Configuration +PASSWORD_HISTORY_LIMIT=5 diff --git a/.gitignore b/.gitignore index 0a58c354..a9987618 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,9 @@ coverage/ # Prisma prisma/migrations/ +!prisma/migrations/ !prisma/migrations/.gitkeep +!prisma/migrations/*.sql # OS files Thumbs.db diff --git a/prisma/migrations/20260422000000_add_auth_security_foundation.sql b/prisma/migrations/20260422000000_add_auth_security_foundation.sql new file mode 100644 index 00000000..06ac6acd --- /dev/null +++ b/prisma/migrations/20260422000000_add_auth_security_foundation.sql @@ -0,0 +1,58 @@ +ALTER TABLE "users" +ADD COLUMN "two_factor_enabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "two_factor_secret" TEXT, +ADD COLUMN "two_factor_backup_codes" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +CREATE TABLE "api_keys" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "key_prefix" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "last_used_at" TIMESTAMP(3), + "expires_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "password_history" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_history_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "blacklisted_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT, + "jti" TEXT NOT NULL, + "token_type" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "blacklisted_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash"); +CREATE INDEX "api_keys_user_id_idx" ON "api_keys"("user_id"); +CREATE INDEX "api_keys_key_prefix_idx" ON "api_keys"("key_prefix"); + +CREATE INDEX "password_history_user_id_created_at_idx" ON "password_history"("user_id", "created_at"); + +CREATE UNIQUE INDEX "blacklisted_tokens_jti_key" ON "blacklisted_tokens"("jti"); +CREATE INDEX "blacklisted_tokens_user_id_idx" ON "blacklisted_tokens"("user_id"); +CREATE INDEX "blacklisted_tokens_expires_at_idx" ON "blacklisted_tokens"("expires_at"); + +ALTER TABLE "api_keys" +ADD CONSTRAINT "api_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE "password_history" +ADD CONSTRAINT "password_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE "blacklisted_tokens" +ADD CONSTRAINT "blacklisted_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2992230f..503a6946 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,11 @@ enum UserRole { ADMIN } +enum TokenType { + ACCESS + REFRESH +} + // Property listing status enum PropertyStatus { DRAFT @@ -64,6 +69,9 @@ model User { phone String? role UserRole @default(USER) isVerified Boolean @default(false) @map("is_verified") + twoFactorEnabled Boolean @default(false) @map("two_factor_enabled") + twoFactorSecret String? @map("two_factor_secret") + twoFactorBackupCodes String[] @default([]) @map("two_factor_backup_codes") avatar String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -73,12 +81,61 @@ model User { buyerTransactions Transaction[] @relation("BuyerTransactions") sellerTransactions Transaction[] @relation("SellerTransactions") documents Document[] + apiKeys ApiKey[] + passwordHistory PasswordHistory[] + blacklistedTokens BlacklistedToken[] @@index([email]) @@index([role]) @@map("users") } +model ApiKey { + id String @id @default(uuid()) + userId String @map("user_id") + name String + keyPrefix String @map("key_prefix") + keyHash String @unique @map("key_hash") + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([keyPrefix]) + @@map("api_keys") +} + +model PasswordHistory { + id String @id @default(uuid()) + userId String @map("user_id") + passwordHash String @map("password_hash") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@map("password_history") +} + +model BlacklistedToken { + id String @id @default(uuid()) + userId String? @map("user_id") + jti String @unique + tokenType TokenType @map("token_type") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@map("blacklisted_tokens") +} + // Property model model Property { id String @id @default(uuid()) diff --git a/src/app.module.ts b/src/app.module.ts index d4ff518e..7e0ca7d3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,6 +4,7 @@ import { UsersModule } from './users/users.module'; import { PropertiesModule } from './properties/properties.module'; import { PrismaModule } from './database/prisma.module'; import { AppController } from './app.controller'; +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ @@ -14,6 +15,7 @@ import { AppController } from './app.controller'; PrismaModule, UsersModule, PropertiesModule, + AuthModule, ], controllers: [AppController], }) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 00000000..17dfbe4d --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,120 @@ +import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { + ChangePasswordDto, + CreateApiKeyDto, + DisableTwoFactorDto, + LoginDto, + LogoutDto, + RefreshTokenDto, + RegisterDto, + VerifyTwoFactorDto, +} from './dto/auth.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { AuthUserPayload } from './types/auth-user.type'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('register') + register(@Body() registerDto: RegisterDto) { + return this.authService.register(registerDto); + } + + @Post('login') + login(@Body() loginDto: LoginDto) { + return this.authService.login(loginDto); + } + + @Post('refresh') + refresh(@Body() refreshTokenDto: RefreshTokenDto) { + return this.authService.refreshToken(refreshTokenDto); + } + + @UseGuards(JwtAuthGuard) + @Post('logout') + logout( + @CurrentUser() user: AuthUserPayload, + @Body() logoutDto: LogoutDto, + @Req() request: { accessToken?: string }, + ) { + return this.authService.logout(user, logoutDto.refreshToken, request.accessToken); + } + + @UseGuards(JwtAuthGuard) + @Get('me') + me(@CurrentUser() user: AuthUserPayload) { + return this.authService.me(user); + } + + @UseGuards(JwtAuthGuard) + @Post('change-password') + changePassword( + @CurrentUser() user: AuthUserPayload, + @Body() changePasswordDto: ChangePasswordDto, + ) { + return this.authService.changePassword(user, changePasswordDto); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/setup') + setupTwoFactor(@CurrentUser() user: AuthUserPayload) { + return this.authService.setupTwoFactor(user); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/verify') + verifyTwoFactor( + @CurrentUser() user: AuthUserPayload, + @Body() verifyTwoFactorDto: VerifyTwoFactorDto, + ) { + return this.authService.verifyTwoFactor(user, verifyTwoFactorDto); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/disable') + disableTwoFactor( + @CurrentUser() user: AuthUserPayload, + @Body() disableTwoFactorDto: DisableTwoFactorDto, + ) { + return this.authService.disableTwoFactor(user, disableTwoFactorDto.password); + } + + @UseGuards(ApiKeyAuthGuard) + @Get('api-keys/validate') + validateApiKey(@CurrentUser() user: AuthUserPayload) { + return { + valid: true, + userId: user.sub, + email: user.email, + apiKeyId: user.apiKeyId, + }; + } + + @UseGuards(JwtAuthGuard) + @Post('api-keys') + createApiKey(@CurrentUser() user: AuthUserPayload, @Body() createApiKeyDto: CreateApiKeyDto) { + return this.authService.createApiKey(user, createApiKeyDto); + } + + @UseGuards(JwtAuthGuard) + @Get('api-keys') + listApiKeys(@CurrentUser() user: AuthUserPayload) { + return this.authService.listApiKeys(user); + } + + @UseGuards(JwtAuthGuard) + @Post('api-keys/:id/rotate') + rotateApiKey(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { + return this.authService.rotateApiKey(user, id); + } + + @UseGuards(JwtAuthGuard) + @Post('api-keys/:id/revoke') + revokeApiKey(@CurrentUser() user: AuthUserPayload, @Param('id') id: string) { + return this.authService.revokeApiKey(user, id); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 00000000..e29811c7 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../database/prisma.module'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; + +@Module({ + imports: [PrismaModule, UsersModule], + controllers: [AuthController], + providers: [AuthService, JwtAuthGuard, ApiKeyAuthGuard], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 00000000..44342a25 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,603 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiKey, Prisma, TokenType, User } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { PrismaService } from '../database/prisma.service'; +import { UsersService } from '../users/users.service'; +import { + ChangePasswordDto, + CreateApiKeyDto, + LoginDto, + RefreshTokenDto, + RegisterDto, + VerifyTwoFactorDto, +} from './dto/auth.dto'; +import { + buildOtpAuthUrl, + buildQrCodeUrl, + comparePassword, + createSha256, + generateBackupCodes, + getPasswordHistoryLimit, + hashPassword, + parseDuration, + randomBase32Secret, + randomToken, + sanitizeUser, + verifyBackupCode, + verifyTotpCode, +} from './security.utils'; +import { AuthUserPayload } from './types/auth-user.type'; + +type JwtPayload = { + sub: string; + email: string; + type: 'access' | 'refresh'; + jti: string; + exp?: number; +}; + +@Injectable() +export class AuthService { + private readonly issuer = 'PropChain'; + private readonly accessTokenTtlSeconds: number; + private readonly refreshTokenTtlSeconds: number; + private readonly jwtSecret: string; + private readonly jwtRefreshSecret: string; + + constructor( + private readonly prisma: PrismaService, + private readonly usersService: UsersService, + private readonly configService: ConfigService, + ) { + this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; + this.jwtRefreshSecret = + this.configService.get('JWT_REFRESH_SECRET') ?? 'propchain-refresh-secret'; + this.accessTokenTtlSeconds = parseDuration( + this.configService.get('JWT_ACCESS_EXPIRES_IN') ?? '15m', + 15 * 60, + ); + this.refreshTokenTtlSeconds = parseDuration( + this.configService.get('JWT_REFRESH_EXPIRES_IN') ?? '7d', + 7 * 24 * 60 * 60, + ); + } + + async register(data: RegisterDto) { + const existingUser = await this.usersService.findByEmail(data.email); + if (existingUser) { + throw new BadRequestException('A user with that email already exists'); + } + + const passwordHash = await hashPassword(data.password); + const user = await this.prisma.user.create({ + data: { + email: data.email, + password: passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone, + passwordHistory: { + create: { + passwordHash, + }, + }, + }, + }); + + const tokens = await this.issueTokenPair(user); + return { + user: sanitizeUser(user), + ...tokens, + }; + } + + async login(data: LoginDto) { + const user = await this.usersService.findByEmail(data.email); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + const passwordMatches = await comparePassword(data.password, user.password); + if (!passwordMatches) { + throw new UnauthorizedException('Invalid credentials'); + } + + if (user.twoFactorEnabled) { + const hasTotpCode = Boolean(data.totpCode?.trim()); + const hasBackupCode = Boolean(data.backupCode?.trim()); + + if (!hasTotpCode && !hasBackupCode) { + throw new UnauthorizedException('Two-factor authentication code required'); + } + + if (hasTotpCode && user.twoFactorSecret) { + const validCode = verifyTotpCode({ + secret: user.twoFactorSecret, + code: data.totpCode!, + }); + + if (!validCode) { + throw new UnauthorizedException('Invalid two-factor authentication code'); + } + } else if (hasBackupCode) { + const matchingBackupCode = verifyBackupCode(data.backupCode!, user.twoFactorBackupCodes); + if (!matchingBackupCode) { + throw new UnauthorizedException('Invalid backup code'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + twoFactorBackupCodes: { + set: user.twoFactorBackupCodes.filter((code: string) => code !== matchingBackupCode), + }, + }, + }); + } + } + + const tokens = await this.issueTokenPair(user); + return { + user: sanitizeUser(user), + ...tokens, + }; + } + + async refreshToken(data: RefreshTokenDto) { + const payload = this.verifyToken(data.refreshToken, this.jwtRefreshSecret) as JwtPayload; + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + + await this.ensureTokenNotBlacklisted(payload.jti); + + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + if (!user) { + throw new UnauthorizedException('User no longer exists'); + } + + if (user.id !== payload.sub) { + throw new UnauthorizedException('Refresh token does not match the authenticated user'); + } + + await this.blacklistToken({ + jti: payload.jti, + tokenType: TokenType.REFRESH, + expiresAt: new Date((payload.exp ?? 0) * 1000), + userId: user.id, + }); + + const tokens = await this.issueTokenPair(user); + return { + user: sanitizeUser(user), + ...tokens, + }; + } + + async logout(user: AuthUserPayload, refreshToken?: string, accessToken?: string) { + if (accessToken) { + const accessPayload = this.verifyToken(accessToken, this.jwtSecret) as JwtPayload; + await this.blacklistToken({ + jti: accessPayload.jti, + tokenType: TokenType.ACCESS, + expiresAt: new Date((accessPayload.exp ?? 0) * 1000), + userId: user.sub, + }); + } + + if (refreshToken) { + const refreshPayload = this.verifyToken(refreshToken, this.jwtRefreshSecret) as JwtPayload; + if (refreshPayload.sub !== user.sub) { + throw new UnauthorizedException('Refresh token does not belong to the current user'); + } + + await this.blacklistToken({ + jti: refreshPayload.jti, + tokenType: TokenType.REFRESH, + expiresAt: new Date((refreshPayload.exp ?? 0) * 1000), + userId: user.sub, + }); + } + + return { message: 'Logged out successfully' }; + } + + async me(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + return sanitizeUser(foundUser); + } + + async changePassword(user: AuthUserPayload, data: ChangePasswordDto) { + const passwordHistoryLimit = getPasswordHistoryLimit(); + const existingUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + include: { + passwordHistory: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!existingUser) { + throw new NotFoundException('User not found'); + } + + const currentPasswordMatches = await comparePassword( + data.currentPassword, + existingUser.password, + ); + if (!currentPasswordMatches) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const passwordReused = await Promise.all( + existingUser.passwordHistory + .slice(0, passwordHistoryLimit) + .map((entry: { passwordHash: string }) => + comparePassword(data.newPassword, entry.passwordHash), + ), + ); + + if (passwordReused.some(Boolean)) { + throw new BadRequestException( + `Password reuse is not allowed for the last ${passwordHistoryLimit} passwords`, + ); + } + + const newPasswordHash = await hashPassword(data.newPassword); + + await this.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await tx.user.update({ + where: { id: existingUser.id }, + data: { + password: newPasswordHash, + }, + }); + + await tx.passwordHistory.create({ + data: { + userId: existingUser.id, + passwordHash: newPasswordHash, + }, + }); + + const historyEntries = await tx.passwordHistory.findMany({ + where: { userId: existingUser.id }, + orderBy: { createdAt: 'desc' }, + skip: passwordHistoryLimit, + }); + + if (historyEntries.length > 0) { + await tx.passwordHistory.deleteMany({ + where: { + id: { + in: historyEntries.map((entry: { id: string }) => entry.id), + }, + }, + }); + } + }); + + return { message: 'Password updated successfully' }; + } + + async setupTwoFactor(user: AuthUserPayload) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const secret = randomBase32Secret(); + const backupCodes = generateBackupCodes(); + const hashedBackupCodes = backupCodes.map((code) => createSha256(code)); + const otpAuthUrl = buildOtpAuthUrl(foundUser.email, secret, this.issuer); + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorSecret: secret, + twoFactorEnabled: false, + twoFactorBackupCodes: { + set: hashedBackupCodes, + }, + }, + }); + + return { + secret, + otpAuthUrl, + qrCodeUrl: buildQrCodeUrl(otpAuthUrl), + backupCodes, + }; + } + + async verifyTwoFactor(user: AuthUserPayload, data: VerifyTwoFactorDto) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser?.twoFactorSecret) { + throw new BadRequestException('Two-factor authentication has not been initialized'); + } + + const validCode = verifyTotpCode({ + secret: foundUser.twoFactorSecret, + code: data.code, + }); + if (!validCode) { + throw new UnauthorizedException('Invalid two-factor authentication code'); + } + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorEnabled: true, + }, + }); + + return { message: 'Two-factor authentication enabled successfully' }; + } + + async disableTwoFactor(user: AuthUserPayload, password: string) { + const foundUser = await this.prisma.user.findUnique({ + where: { id: user.sub }, + }); + + if (!foundUser) { + throw new NotFoundException('User not found'); + } + + const passwordMatches = await comparePassword(password, foundUser.password); + if (!passwordMatches) { + throw new UnauthorizedException('Password is incorrect'); + } + + await this.prisma.user.update({ + where: { id: foundUser.id }, + data: { + twoFactorEnabled: false, + twoFactorSecret: null, + twoFactorBackupCodes: { + set: [], + }, + }, + }); + + return { message: 'Two-factor authentication disabled successfully' }; + } + + async createApiKey(user: AuthUserPayload, data: CreateApiKeyDto) { + const apiKeyValue = this.generateApiKeyValue(); + const record = await this.prisma.apiKey.create({ + data: { + userId: user.sub, + name: data.name, + keyPrefix: apiKeyValue.slice(0, 12), + keyHash: createSha256(apiKeyValue), + expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + }, + }); + + return { + apiKey: apiKeyValue, + details: this.toApiKeyResponse(record), + }; + } + + async listApiKeys(user: AuthUserPayload) { + const apiKeys = await this.prisma.apiKey.findMany({ + where: { userId: user.sub }, + orderBy: { createdAt: 'desc' }, + }); + + return apiKeys.map((apiKey: ApiKey) => this.toApiKeyResponse(apiKey)); + } + + async rotateApiKey(user: AuthUserPayload, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + revokedAt: new Date(), + }, + }); + + return this.createApiKey(user, { + name: apiKey.name, + expiresAt: apiKey.expiresAt?.toISOString(), + }); + } + + async revokeApiKey(user: AuthUserPayload, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId: user.sub, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + revokedAt: new Date(), + }, + }); + + return { message: 'API key revoked successfully' }; + } + + async validateAccessToken(token: string): Promise { + const payload = this.verifyToken(token, this.jwtSecret) as JwtPayload; + + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid access token'); + } + + await this.ensureTokenNotBlacklisted(payload.jti); + + return { + sub: payload.sub, + email: payload.email, + type: 'access', + jti: payload.jti, + }; + } + + async validateApiKey(apiKeyValue: string): Promise { + const apiKey = await this.prisma.apiKey.findUnique({ + where: { + keyHash: createSha256(apiKeyValue), + }, + include: { + user: true, + }, + }); + + if (!apiKey || apiKey.revokedAt || (apiKey.expiresAt && apiKey.expiresAt < new Date())) { + throw new UnauthorizedException('Invalid API key'); + } + + await this.prisma.apiKey.update({ + where: { id: apiKey.id }, + data: { + lastUsedAt: new Date(), + }, + }); + + return { + sub: apiKey.userId, + email: apiKey.user.email, + type: 'api-key', + apiKeyId: apiKey.id, + }; + } + + private async issueTokenPair(user: User) { + const accessJti = randomUUID(); + const refreshJti = randomUUID(); + + const accessToken = this.signToken( + { + sub: user.id, + email: user.email, + type: 'access', + jti: accessJti, + }, + this.jwtSecret, + this.accessTokenTtlSeconds, + ); + + const refreshToken = this.signToken( + { + sub: user.id, + email: user.email, + type: 'refresh', + jti: refreshJti, + }, + this.jwtRefreshSecret, + this.refreshTokenTtlSeconds, + ); + + return { + accessToken, + refreshToken, + accessTokenExpiresIn: this.accessTokenTtlSeconds, + refreshTokenExpiresIn: this.refreshTokenTtlSeconds, + }; + } + + private signToken(payload: JwtPayload, secret: string, expiresInSeconds: number) { + return jwt.sign(payload, secret, { + expiresIn: expiresInSeconds, + issuer: this.issuer, + }); + } + + private verifyToken(token: string, secret: string) { + try { + return jwt.verify(token, secret, { + issuer: this.issuer, + }) as JwtPayload & { exp?: number }; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private async ensureTokenNotBlacklisted(jti: string) { + const blacklistedToken = await this.prisma.blacklistedToken.findUnique({ + where: { jti }, + }); + + if (blacklistedToken) { + throw new UnauthorizedException('Token has been revoked'); + } + } + + private async blacklistToken(data: { + jti: string; + tokenType: TokenType; + expiresAt: Date; + userId?: string; + }) { + await this.prisma.blacklistedToken.upsert({ + where: { jti: data.jti }, + update: { + expiresAt: data.expiresAt, + tokenType: data.tokenType, + userId: data.userId, + }, + create: data, + }); + } + + private generateApiKeyValue() { + return `pc_${randomToken(24)}`; + } + + private toApiKeyResponse(apiKey: ApiKey) { + return { + id: apiKey.id, + name: apiKey.name, + keyPrefix: apiKey.keyPrefix, + lastUsedAt: apiKey.lastUsedAt, + expiresAt: apiKey.expiresAt, + revokedAt: apiKey.revokedAt, + createdAt: apiKey.createdAt, + updatedAt: apiKey.updatedAt, + }; + } +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 00000000..daf217d8 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUserPayload } from '../types/auth-user.type'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthUserPayload => { + const request = context.switchToHttp().getRequest(); + return request.authUser; + }, +); diff --git a/src/auth/dto/auth.dto.ts b/src/auth/dto/auth.dto.ts new file mode 100644 index 00000000..584e90ef --- /dev/null +++ b/src/auth/dto/auth.dto.ts @@ -0,0 +1,85 @@ +import { + IsDateString, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class RegisterDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; + + @IsString() + @IsNotEmpty() + firstName: string; + + @IsString() + @IsNotEmpty() + lastName: string; + + @IsOptional() + @IsString() + phone?: string; +} + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + password: string; + + @IsOptional() + @IsString() + totpCode?: string; + + @IsOptional() + @IsString() + backupCode?: string; +} + +export class RefreshTokenDto { + @IsString() + refreshToken: string; +} + +export class LogoutDto { + @IsOptional() + @IsString() + refreshToken?: string; +} + +export class ChangePasswordDto { + @IsString() + currentPassword: string; + + @IsString() + @MinLength(8) + newPassword: string; +} + +export class VerifyTwoFactorDto { + @IsString() + code: string; +} + +export class DisableTwoFactorDto { + @IsString() + password: string; +} + +export class CreateApiKeyDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/src/auth/guards/api-key-auth.guard.ts b/src/auth/guards/api-key-auth.guard.ts new file mode 100644 index 00000000..bce60cb8 --- /dev/null +++ b/src/auth/guards/api-key-auth.guard.ts @@ -0,0 +1,32 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class ApiKeyAuthGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const apiKey = this.extractApiKey(request.headers['x-api-key'], request.headers.authorization); + + if (!apiKey) { + throw new UnauthorizedException('Missing API key'); + } + + request.authUser = await this.authService.validateApiKey(apiKey); + return true; + } + + private extractApiKey(xApiKey?: string | string[], authorizationHeader?: string): string | null { + if (typeof xApiKey === 'string' && xApiKey.trim()) { + return xApiKey.trim(); + } + + if (!authorizationHeader) { + return null; + } + + const [scheme, token] = authorizationHeader.split(' '); + return scheme === 'ApiKey' && token ? token : null; + } +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..81c0bfe6 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private readonly authService: AuthService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authorizationHeader = request.headers.authorization; + const token = this.extractBearerToken(authorizationHeader); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + request.authUser = await this.authService.validateAccessToken(token); + request.accessToken = token; + return true; + } + + private extractBearerToken(header?: string): string | null { + if (!header) { + return null; + } + + const [scheme, token] = header.split(' '); + return scheme === 'Bearer' && token ? token : null; + } +} diff --git a/src/auth/security.utils.ts b/src/auth/security.utils.ts new file mode 100644 index 00000000..59b586ac --- /dev/null +++ b/src/auth/security.utils.ts @@ -0,0 +1,173 @@ +import * as bcrypt from 'bcrypt'; +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export type TotpOptions = { + secret: string; + digits?: number; + period?: number; + window?: number; + timestamp?: number; +}; + +export type VerifyTotpOptions = TotpOptions & { + code: string; +}; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} + +export async function comparePassword(password: string, passwordHash: string): Promise { + return bcrypt.compare(password, passwordHash); +} + +export function sanitizeUser>(user: T) { + const safeUser = { ...user }; + delete safeUser.password; + delete safeUser.twoFactorSecret; + delete safeUser.twoFactorBackupCodes; + return safeUser; +} + +export function createSha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +export function randomToken(size = 32): string { + return randomBytes(size).toString('hex'); +} + +export function randomBase32Secret(length = 32): string { + let secret = ''; + while (secret.length < length) { + const nextIndex = randomBytes(1)[0] % BASE32_ALPHABET.length; + secret += BASE32_ALPHABET[nextIndex]; + } + return secret; +} + +export function decodeBase32(input: string): Buffer { + const normalized = input.replace(/=+$/g, '').toUpperCase(); + let bits = 0; + let value = 0; + const output: number[] = []; + + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) { + continue; + } + + value = (value << 5) | index; + bits += 5; + + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + + return Buffer.from(output); +} + +export function generateBackupCodes(count = 8): string[] { + return Array.from({ length: count }, () => randomBytes(4).toString('hex').toUpperCase()); +} + +export function getPasswordHistoryLimit(): number { + const parsed = Number(process.env.PASSWORD_HISTORY_LIMIT ?? 5); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 5; +} + +export function verifyBackupCode(candidate: string, backupCodeHashes: string[]) { + const digest = createSha256(candidate.trim().toUpperCase()); + const digestBuffer = Buffer.from(digest); + + return backupCodeHashes.find((hash) => { + const hashBuffer = Buffer.from(hash); + return digestBuffer.length === hashBuffer.length && timingSafeEqual(digestBuffer, hashBuffer); + }); +} + +export function buildOtpAuthUrl(email: string, secret: string, issuer = 'PropChain'): string { + const label = encodeURIComponent(`${issuer}:${email}`); + const encodedIssuer = encodeURIComponent(issuer); + return `otpauth://totp/${label}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`; +} + +export function buildQrCodeUrl(otpAuthUrl: string): string { + return `https://quickchart.io/qr?text=${encodeURIComponent(otpAuthUrl)}`; +} + +export function generateTotpCode({ + secret, + digits = 6, + period = 30, + timestamp = Date.now(), +}: TotpOptions): string { + const counter = Math.floor(timestamp / 1000 / period); + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + + const hmac = createHmac('sha1', decodeBase32(secret)).update(counterBuffer).digest(); + const offset = hmac[hmac.length - 1] & 0x0f; + const binaryCode = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + return (binaryCode % 10 ** digits).toString().padStart(digits, '0'); +} + +export function verifyTotpCode({ + secret, + code, + digits = 6, + period = 30, + window = 1, + timestamp = Date.now(), +}: VerifyTotpOptions): boolean { + const normalizedCode = code.trim(); + + for (let offset = -window; offset <= window; offset += 1) { + const expectedCode = generateTotpCode({ + secret, + digits, + period, + timestamp: timestamp + offset * period * 1000, + }); + + if (expectedCode === normalizedCode) { + return true; + } + } + + return false; +} + +export function parseDuration(input: string, fallbackSeconds: number): number { + const value = input?.trim(); + if (!value) { + return fallbackSeconds; + } + + const match = /^(\d+)([smhd])$/i.exec(value); + if (!match) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackSeconds; + } + + const amount = Number(match[1]); + const unit = match[2].toLowerCase(); + const multipliers: Record = { + s: 1, + m: 60, + h: 60 * 60, + d: 60 * 60 * 24, + }; + + return amount * multipliers[unit]; +} diff --git a/src/auth/types/auth-user.type.ts b/src/auth/types/auth-user.type.ts new file mode 100644 index 00000000..1a0df4a3 --- /dev/null +++ b/src/auth/types/auth-user.type.ts @@ -0,0 +1,7 @@ +export type AuthUserPayload = { + sub: string; + email: string; + type: 'access' | 'refresh' | 'api-key'; + jti?: string; + apiKeyId?: string; +}; diff --git a/src/main.ts b/src/main.ts index 4aab2342..05f54afe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +const logger = new Logger('Bootstrap'); + async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -22,6 +24,6 @@ async function bootstrap() { const port = process.env.PORT || 3000; await app.listen(port); - console.log(`🚀 PropChain API running on http://localhost:${port}`); + logger.log(`PropChain API running on http://localhost:${port}`); } bootstrap(); diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index 6f42ef7d..df1244dd 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../database/prisma.service'; import { CreatePropertyDto, UpdatePropertyDto } from './dto/property.dto'; @@ -22,7 +24,12 @@ export class PropertiesService { }); } - async findAll(params?: { skip?: number; take?: number; where?: any; orderBy?: any }) { + async findAll(params?: { + skip?: number; + take?: number; + where?: Prisma.PropertyWhereInput; + orderBy?: Prisma.PropertyOrderByWithRelationInput; + }) { const { skip, take, where, orderBy } = params || {}; return this.prisma.property.findMany({ skip, @@ -86,6 +93,3 @@ export class PropertiesService { }); } } - -// Import Decimal at the top -import { Decimal } from '@prisma/client/runtime/library'; diff --git a/src/users/dto/user.dto.ts b/src/users/dto/user.dto.ts index 5a651a13..9062589c 100644 --- a/src/users/dto/user.dto.ts +++ b/src/users/dto/user.dto.ts @@ -1,10 +1,11 @@ -import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsString() + @MinLength(8) password: string; @IsString() diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 057c2388..682e288b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,25 +1,31 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; import { CreateUserDto, UpdateUserDto } from './dto/user.dto'; +import { hashPassword, sanitizeUser } from '../auth/security.utils'; @Injectable() export class UsersService { constructor(private prisma: PrismaService) {} async create(data: CreateUserDto) { - return this.prisma.user.create({ - data, - select: { - id: true, - email: true, - firstName: true, - lastName: true, - phone: true, - role: true, - isVerified: true, - createdAt: true, + const passwordHash = await hashPassword(data.password); + + const user = await this.prisma.user.create({ + data: { + email: data.email, + password: passwordHash, + firstName: data.firstName, + lastName: data.lastName, + phone: data.phone, + passwordHistory: { + create: { + passwordHash, + }, + }, }, }); + + return sanitizeUser(user); } async findAll() {