2026-04-15-4
This commit is contained in:
@@ -6,6 +6,8 @@ import { ApiKey } from './entities/api-key.entity';
|
||||
import { RedisModule } from '../common/redis.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { FirebaseAdminService } from './firebase-admin.service';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -14,8 +16,8 @@ import { TenantController } from './tenant.controller';
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [TenantController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
providers: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
exports: [AuthService, FirebaseAdminService, FirebaseAuthGuard],
|
||||
})
|
||||
export class AuthModule implements OnModuleInit {
|
||||
constructor(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createHash } from 'crypto';
|
||||
import { ApiKey } from './entities/api-key.entity';
|
||||
import { Tenant } from './entities/tenant.entity';
|
||||
import { Tenant, TenantPlan } from './entities/tenant.entity';
|
||||
import { RedisService } from '../common/redis.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,7 +16,7 @@ export class AuthService {
|
||||
@InjectRepository(Tenant)
|
||||
private readonly tenantRepository: Repository<Tenant>,
|
||||
private readonly redisService: RedisService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Validate an API key and check its restrictions (Origin/Referer).
|
||||
@@ -26,7 +26,7 @@ export class AuthService {
|
||||
// 1. Check Redis Cache first
|
||||
const cacheKey = `auth:apikey:${key}`;
|
||||
const cachedData = await this.redisService.get<{ tenant: Tenant; apiKey: ApiKey; rateLimit: number }>(cacheKey);
|
||||
|
||||
|
||||
if (cachedData) {
|
||||
this.validateRestrictions(cachedData.apiKey, origin, referer);
|
||||
return cachedData;
|
||||
@@ -53,9 +53,9 @@ export class AuthService {
|
||||
|
||||
// 4. Update Cache (TTL 1 hour)
|
||||
await this.redisService.set(cacheKey, result, 3600);
|
||||
|
||||
|
||||
// 5. Update lastUsedAt asynchronously
|
||||
this.apiKeyRepository.update(apiKey.id, { lastUsedAt: new Date() }).catch(err =>
|
||||
this.apiKeyRepository.update(apiKey.id, { lastUsedAt: new Date() }).catch(err =>
|
||||
this.logger.error(`Failed to update lastUsedAt for API key ${apiKey.id}: ${err.message}`)
|
||||
);
|
||||
|
||||
@@ -105,10 +105,10 @@ export class AuthService {
|
||||
async seedDefaultKey(name: string, email: string, keyString: string): Promise<void> {
|
||||
let tenant = await this.tenantRepository.findOne({ where: { email } });
|
||||
if (!tenant) {
|
||||
tenant = await this.tenantRepository.save({
|
||||
name,
|
||||
email,
|
||||
isActive: true
|
||||
tenant = await this.tenantRepository.save({
|
||||
name,
|
||||
email,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const key = `in_${createHash('md5').update(Math.random().toString()).digest('hex').substring(0, 24)}`;
|
||||
|
||||
|
||||
const apiKey = this.apiKeyRepository.create({
|
||||
key,
|
||||
secretHash: this.hashSecret(key),
|
||||
@@ -164,4 +164,51 @@ export class AuthService {
|
||||
if (!tenant) throw new NotFoundException('No tenants found in system');
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tenant by Firebase UID or create one if it doesn't exist.
|
||||
* Supports linking Google accounts to existing email-based tenants.
|
||||
*/
|
||||
async findOrCreateByFirebaseUid(uid: string, email: string, name: string, photoUrl?: string): Promise<Tenant> {
|
||||
// 1. Try finding by Firebase UID
|
||||
let tenant = await this.tenantRepository.findOne({ where: { firebaseUid: uid } });
|
||||
|
||||
if (!tenant) {
|
||||
// 2. Try finding by email for account linking
|
||||
tenant = await this.tenantRepository.findOne({ where: { email } });
|
||||
|
||||
if (tenant) {
|
||||
this.logger.log(`Linking existing tenant ${email} to Firebase UID: ${uid}`);
|
||||
tenant.firebaseUid = uid;
|
||||
if (photoUrl) tenant.photoUrl = photoUrl;
|
||||
tenant = await this.tenantRepository.save(tenant);
|
||||
} else {
|
||||
// 3. Create new if neither found
|
||||
this.logger.log(`Creating new tenant for Firebase user: ${email} (${uid})`);
|
||||
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
|
||||
tenant = await this.tenantRepository.save({
|
||||
firebaseUid: uid,
|
||||
email,
|
||||
name,
|
||||
photoUrl,
|
||||
plan: isAdmin ? TenantPlan.ENTERPRISE : TenantPlan.FREE,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Auto-upgrade admins if they exist but are on lower plan
|
||||
const isAdmin = email === 'hamzaaleghwairyeen@gmail.com' || email === 'hamzadoctor@gmail.com';
|
||||
if (isAdmin && tenant.plan !== TenantPlan.ENTERPRISE) {
|
||||
tenant.plan = TenantPlan.ENTERPRISE;
|
||||
await this.tenantRepository.save(tenant);
|
||||
}
|
||||
|
||||
// Update info if changed
|
||||
if (tenant.photoUrl !== photoUrl || tenant.name !== name) {
|
||||
await this.tenantRepository.update(tenant.id, { photoUrl, name });
|
||||
}
|
||||
}
|
||||
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { ApiKey } from './api-key.entity';
|
||||
|
||||
export enum TenantPlan {
|
||||
FREE = 'FREE',
|
||||
PREMIUM = 'PREMIUM',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
||||
|
||||
export enum TenantRole {
|
||||
USER = 'USER',
|
||||
ADMIN = 'ADMIN',
|
||||
}
|
||||
|
||||
@Entity('tenants')
|
||||
export class Tenant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -25,6 +30,19 @@ export class Tenant {
|
||||
})
|
||||
plan: TenantPlan;
|
||||
|
||||
@Column({ nullable: true, unique: true })
|
||||
firebaseUid: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TenantRole,
|
||||
default: TenantRole.USER,
|
||||
})
|
||||
role: TenantRole;
|
||||
|
||||
@Column({ nullable: true })
|
||||
photoUrl: string;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class FirebaseAdminService implements OnModuleInit {
|
||||
private readonly logger = new Logger(FirebaseAdminService.name);
|
||||
private firebaseApp: admin.app.App;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const keyPath = this.configService.get<string>('FIREBASE_SERVICE_ACCOUNT_PATH');
|
||||
|
||||
if (!keyPath) {
|
||||
this.logger.error('FIREBASE_SERVICE_ACCOUNT_PATH is not defined in environment variables');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve path relative to workspace root if it's not absolute
|
||||
const absolutePath = path.isAbsolute(keyPath)
|
||||
? keyPath
|
||||
: path.resolve(process.cwd(), keyPath);
|
||||
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
this.logger.error(`Firebase service account key not found at: ${absolutePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceAccount = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
||||
|
||||
this.firebaseApp = admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount),
|
||||
});
|
||||
|
||||
this.logger.log('✅ Firebase Admin SDK initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to initialize Firebase Admin SDK: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyIdToken(token: string): Promise<admin.auth.DecodedIdToken> {
|
||||
try {
|
||||
return await admin.auth().verifyIdToken(token);
|
||||
} catch (error) {
|
||||
this.logger.error(`Token verification failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { FirebaseAdminService } from '../firebase-admin.service';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class FirebaseAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(FirebaseAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
private firebaseAdminService: FirebaseAdminService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing or invalid Authorization header');
|
||||
}
|
||||
|
||||
const token = authHeader.split('Bearer ')[1];
|
||||
|
||||
try {
|
||||
// 1. Verify Firebase ID Token
|
||||
const decodedToken = await this.firebaseAdminService.verifyIdToken(token);
|
||||
|
||||
// 2. Find or create Tenant based on Firebase info
|
||||
const tenant = await this.authService.findOrCreateByFirebaseUid(
|
||||
decodedToken.uid,
|
||||
decodedToken.email || '',
|
||||
decodedToken.name || decodedToken.email?.split('@')[0] || 'Unknown User',
|
||||
decodedToken.picture,
|
||||
);
|
||||
|
||||
if (!tenant.isActive) {
|
||||
throw new UnauthorizedException('Tenant account is disabled');
|
||||
}
|
||||
|
||||
// 3. Attach tenant to request for controllers
|
||||
request['tenant'] = tenant;
|
||||
request['user'] = decodedToken;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`Firebase Auth failed: ${error.message}`);
|
||||
throw new UnauthorizedException(error.message || 'Authentication failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,23 @@
|
||||
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Req } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CreateKeyDto } from './dto/management/create-key.dto';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FirebaseAuthGuard } from './guards/firebase-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(FirebaseAuthGuard)
|
||||
@Controller('auth/management')
|
||||
export class TenantController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get('keys/:tenantId')
|
||||
@ApiOperation({ summary: 'Get all API keys for a tenant' })
|
||||
async getKeys(@Param('tenantId') tenantId: string) {
|
||||
return this.authService.getApiKeys(tenantId);
|
||||
}
|
||||
|
||||
@Post('keys/:tenantId')
|
||||
@Post('keys')
|
||||
@ApiOperation({ summary: 'Create a new API key' })
|
||||
async createKey(
|
||||
@Param('tenantId') tenantId: string,
|
||||
@Req() req: any,
|
||||
@Body() dto: CreateKeyDto
|
||||
) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.authService.createApiKey(
|
||||
tenantId,
|
||||
dto.name,
|
||||
@@ -28,9 +26,16 @@ export class TenantController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('keys')
|
||||
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
|
||||
async getKeys(@Req() req: any) {
|
||||
const tenantId = req.tenant.id;
|
||||
return this.authService.getApiKeys(tenantId);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Identify the default tenant (Demo Only)' })
|
||||
async getMe() {
|
||||
return this.authService.getDefaultTenant();
|
||||
@ApiOperation({ summary: 'Get current authenticated tenant info' })
|
||||
async getMe(@Req() req: any) {
|
||||
return req.tenant;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user