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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ coverage/

# Prisma
prisma/migrations/
!prisma/migrations/
!prisma/migrations/.gitkeep
!prisma/migrations/*.sql

# OS files
Thumbs.db
Expand Down
58 changes: 58 additions & 0 deletions prisma/migrations/20260422000000_add_auth_security_foundation.sql
Original file line number Diff line number Diff line change
@@ -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;
57 changes: 57 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ enum UserRole {
ADMIN
}

enum TokenType {
ACCESS
REFRESH
}

// Property listing status
enum PropertyStatus {
DRAFT
Expand Down Expand Up @@ -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")
Expand All @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -14,6 +15,7 @@ import { AppController } from './app.controller';
PrismaModule,
UsersModule,
PropertiesModule,
AuthModule,
],
controllers: [AppController],
})
Expand Down
120 changes: 120 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 15 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading