2026-04-15-4

This commit is contained in:
Hamza-Ayed
2026-04-15 19:56:49 +03:00
parent 9cd1ac4c1d
commit 3d61362602
54 changed files with 12659 additions and 436 deletions
+4 -2
View File
@@ -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(
+57 -10
View File
@@ -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;
}
}
+19 -1
View File
@@ -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');
}
}
}
+18 -13
View File
@@ -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;
}
}