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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ JWT_REFRESH_EXPIRES_IN=7d
# Security Configuration
BCRYPT_ROUNDS=12
PASSWORD_HISTORY_LIMIT=5

# Google OAuth2
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback
3,577 changes: 2,801 additions & 776 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 @@ -32,6 +32,7 @@
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/graphql": "^12.2.2",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.22",
Expand All @@ -53,6 +54,7 @@
"graphql-subscriptions": "^3.0.0",
"jsonwebtoken": "^9.0.2",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pdf-poppler": "^0.2.3",
"pg": "^8.11.3",
Expand All @@ -73,6 +75,7 @@
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.10.4",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^3.0.13",
"@typescript-eslint/eslint-plugin": "^6.13.1",
"@typescript-eslint/parser": "^6.13.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable: make password optional and add googleId
ALTER TABLE "users" ALTER COLUMN "password" DROP NOT NULL;
ALTER TABLE "users" ADD COLUMN "google_id" TEXT;
CREATE UNIQUE INDEX "users_google_id_key" ON "users"("google_id");
3 changes: 2 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ enum BounceType {
model User {
id String @id @default(uuid())
email String @unique
password String
password String?
googleId String? @unique @map("google_id")
firstName String @map("first_name")
lastName String @map("last_name")
phone String?
Expand Down
14 changes: 14 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import {
} from './dto/auth.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { ApiKeyAuthGuard } from './guards/api-key-auth.guard';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { CurrentUser } from './decorators/current-user.decorator';
import { Roles } from './decorators/roles.decorator';
import { AuthUserPayload } from './types/auth-user.type';
import { GoogleProfile } from './strategies/google.strategy';
import { UserRole } from '../types/prisma.types';
import { Request } from 'express';

Expand All @@ -31,6 +33,18 @@ export class AuthController {
return this.authService.register(registerDto);
}

@UseGuards(GoogleAuthGuard)
@Get('google')
googleLogin() {
// Initiates Google OAuth2 redirect — handled by passport
}

@UseGuards(GoogleAuthGuard)
@Get('google/callback')
googleCallback(@Req() req: { user: GoogleProfile }) {
return this.authService.googleOAuthLogin(req.user);
}

@Post('login')
login(@Body() loginDto: LoginDto, @Req() request: Request) {
const ipAddress = request.ip || request.socket.remoteAddress;
Expand Down
5 changes: 4 additions & 1 deletion src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../database/prisma.module';
import { UsersModule } from '../users/users.module';
import { SessionsModule } from '../sessions/sessions.module';
Expand All @@ -9,14 +10,15 @@ import { LoginRateLimitService } from './login-rate-limit.service';
import { RateLimitService } from './rate-limit.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { ApiKeyAuthGuard } from './guards/api-key-auth.guard';
import { GoogleStrategy } from './strategies/google.strategy';
import { RolesGuard } from './guards/roles.guard';
import { RateLimitGuard } from './guards/rate-limit.guard';
import { RateLimitHeadersInterceptor } from './interceptors/rate-limit-headers.interceptor';
import { RateLimitAdminController } from './controllers/rate-limit-admin.controller';
import { FraudModule } from '../fraud/fraud.module';

@Module({
imports: [PrismaModule, UsersModule, SessionsModule, EmailModule, FraudModule],
imports: [PrismaModule, UsersModule, SessionsModule, EmailModule, FraudModule, PassportModule],
controllers: [AuthController, RateLimitAdminController],
providers: [
AuthService,
Expand All @@ -27,6 +29,7 @@ import { FraudModule } from '../fraud/fraud.module';
RolesGuard,
RateLimitGuard,
RateLimitHeadersInterceptor,
GoogleStrategy,
],
exports: [
AuthService,
Expand Down
52 changes: 49 additions & 3 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { User as PrismaUser, ApiKey, TokenType } from '@prisma/client';

Check warning on line 9 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

'TokenType' is defined but never used

Check warning on line 9 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

'ApiKey' is defined but never used
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as jwt from 'jsonwebtoken';
Expand Down Expand Up @@ -41,6 +41,7 @@
verifyTotpCode,
} from './security.utils';
import { AuthUserPayload } from './types/auth-user.type';
import { GoogleProfile } from './strategies/google.strategy';

import { LoginRateLimitService } from './login-rate-limit.service';
import { UserRole } from '../types/prisma.types';
Expand Down Expand Up @@ -92,7 +93,7 @@
/**
* Helper to map transactions to activity items for dashboard
*/
private transactionsToActivityItems(transactions: any[], type: 'purchase' | 'sale') {

Check warning on line 96 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
return transactions.map((tx) => ({
type: 'transaction' as const,
id: tx.id,
Expand Down Expand Up @@ -176,7 +177,7 @@
);
}

const passwordMatches = await comparePassword(data.password, user.password);
const passwordMatches = await comparePassword(data.password, user.password ?? '');
if (!passwordMatches) {
// Record failed login attempt
const shouldLock = await this.rateLimitService.recordFailedAttempt(
Expand Down Expand Up @@ -331,7 +332,7 @@
* Handle token reuse detection - invalidate entire token family
*/
private async handleTokenReuse(
blacklistedToken: any,

Check warning on line 335 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
reusedJti: string,
ipAddress?: string,
userAgent?: string,
Expand Down Expand Up @@ -501,7 +502,7 @@
throw new NotFoundException('User not found');
}

const [properties, buyerTransactions, sellerTransactions, documents, apiKeys] =

Check warning on line 505 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

'properties' is assigned a value but never used
await Promise.all([
this.prisma.property.findMany({
where: { ownerId: user.sub },
Expand Down Expand Up @@ -607,7 +608,7 @@
const recentActivity = [
...this.transactionsToActivityItems(buyerTransactions, 'purchase'),
...this.transactionsToActivityItems(sellerTransactions, 'sale'),
...documents.map((doc: any) => ({

Check warning on line 611 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
type: 'document' as const,
id: doc.id,
title: doc.fileName,
Expand All @@ -631,7 +632,7 @@
apiKeysCount: apiKeys.length,
},
recentActivity,
recommendations: recommendationProperties.map((p: any) => ({

Check warning on line 635 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
id: p.id,
title: p.title,
address: p.address,
Expand Down Expand Up @@ -666,7 +667,7 @@

const currentPasswordMatches = await comparePassword(
data.currentPassword,
existingUser.password,
existingUser.password ?? '',
);
if (!currentPasswordMatches) {
throw new UnauthorizedException('Current password is incorrect');
Expand Down Expand Up @@ -792,7 +793,7 @@
throw new NotFoundException('User not found');
}

const passwordMatches = await comparePassword(password, foundUser.password);
const passwordMatches = await comparePassword(password, foundUser.password ?? '');
if (!passwordMatches) {
throw new UnauthorizedException('Password is incorrect');
}
Expand Down Expand Up @@ -837,7 +838,7 @@
orderBy: { createdAt: 'desc' },
});

return apiKeys.map((apiKey: any) => this.toApiKeyResponse(apiKey));

Check warning on line 841 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
}

async rotateApiKey(user: AuthUserPayload, apiKeyId: string) {
Expand Down Expand Up @@ -888,6 +889,51 @@
return { message: 'API key revoked successfully' };
}

async googleOAuthLogin(profile: GoogleProfile) {
let user = await this.prisma.user.findUnique({ where: { googleId: profile.googleId } });

if (!user) {
// Try to link to an existing account by email
user = await this.prisma.user.findUnique({ where: { email: profile.email } });

if (user) {
// Link Google account to existing user
user = await this.prisma.user.update({
where: { id: user.id },
data: {
googleId: profile.googleId,
avatar: user.avatar ?? profile.avatar,
},
});
} else {
// Create new user from Google profile
user = await this.prisma.user.create({
data: {
email: profile.email,
googleId: profile.googleId,
firstName: profile.firstName,
lastName: profile.lastName,
avatar: profile.avatar,
isVerified: true,
},
});
}
} else {
// Sync profile fields
user = await this.prisma.user.update({
where: { id: user.id },
data: {
firstName: profile.firstName,
lastName: profile.lastName,
avatar: user.avatar ?? profile.avatar,
},
});
}

const tokens = await this.issueTokenPair(user);
return { user: sanitizeUser(user), ...tokens };
}

async updateApiKeyPermissions(
user: AuthUserPayload,
apiKeyId: string,
Expand Down Expand Up @@ -1136,7 +1182,7 @@
return `pc_${randomToken(24)}`;
}

private toApiKeyResponse(apiKey: any) {

Check warning on line 1185 in src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & Build

Unexpected any. Specify a different type
return {
id: apiKey.id,
name: apiKey.name,
Expand Down
5 changes: 5 additions & 0 deletions src/auth/guards/google-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleAuthGuard extends AuthGuard('google') {}
41 changes: 41 additions & 0 deletions src/auth/strategies/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback, Profile } from 'passport-google-oauth20';

export type GoogleProfile = {
googleId: string;
email: string;
firstName: string;
lastName: string;
avatar?: string;
};

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(configService: ConfigService) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID') ?? '',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') ?? '',
callbackURL: configService.get<string>('GOOGLE_CALLBACK_URL') ?? '/api/auth/google/callback',
scope: ['email', 'profile'],
});
}

validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: VerifyCallback,
): void {
const { id, name, emails, photos } = profile;
const googleProfile: GoogleProfile = {
googleId: id,
email: emails?.[0]?.value ?? '',
firstName: name?.givenName ?? '',
lastName: name?.familyName ?? '',
avatar: photos?.[0]?.value,
};
done(null, googleProfile);
}
}
Loading