diff --git a/.env.api b/.env.api index 1aaf393..4e9c444 100644 --- a/.env.api +++ b/.env.api @@ -8,3 +8,6 @@ REDIS_URL="redis://redis:6379" # JWT, multi-tenant hints, etc. JWT_SECRET="devsecret" TENANCY_STRATEGY="single-db" + + +CENTRAL_SUBDOMAINS="central,admin" diff --git a/CENTRAL_ADMIN_AUTH_GUIDE.md b/CENTRAL_ADMIN_AUTH_GUIDE.md new file mode 100644 index 0000000..af6138b --- /dev/null +++ b/CENTRAL_ADMIN_AUTH_GUIDE.md @@ -0,0 +1,231 @@ +# Central Admin Authentication Guide + +## Overview + +The platform now supports **two types of authentication**: + +1. **Tenant Login** - Authenticates users against a specific tenant's database +2. **Central Admin Login** - Authenticates administrators against the central platform database + +## Central vs Tenant Authentication + +### Tenant Authentication (Default) +- Users login to their specific tenant database +- Each tenant has isolated user tables +- Access is scoped to the tenant's data +- API Endpoint: `/api/auth/login` +- Requires `x-tenant-id` header or subdomain detection + +### Central Admin Authentication +- Administrators login to the central platform database +- Can manage all tenants and platform-wide features +- Users stored in the central database `users` table +- API Endpoint: `/api/central/auth/login` +- No tenant ID required + +## Creating a Central Admin User + +### Quick Start + +```bash +cd backend +npm run create-central-admin +``` + +Follow the interactive prompts to create your admin user. + +### Environment Variable Method + +```bash +EMAIL=admin@platform.com \ +PASSWORD=SecureP@ssw0rd \ +FIRST_NAME=Admin \ +LAST_NAME=User \ +ROLE=superadmin \ +npm run create-central-admin +``` + +### Role Types + +- **admin** - Standard administrator with platform management access +- **superadmin** - Super administrator with full platform access + +## Logging In as Central Admin + +### Frontend Login + +1. Navigate to the login page (`/login`) +2. **Check the "Login as Central Admin" checkbox** +3. Enter your central admin email and password +4. Click "Login to Central" + +The checkbox toggles between: +- ✅ **Checked** - Authenticates against central database +- ⬜ **Unchecked** - Authenticates against tenant database (default) + +### API Login (Direct) + +**Central Admin Login:** +```bash +curl -X POST http://localhost:3000/api/central/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@platform.com", + "password": "SecureP@ssw0rd" + }' +``` + +**Response:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "user": { + "id": "cm5a1b2c3d4e5f6g7h8i9j0k", + "email": "admin@platform.com", + "firstName": "Admin", + "lastName": "User", + "role": "superadmin", + "isCentralAdmin": true + } +} +``` + +**Tenant Login (for comparison):** +```bash +curl -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -H "x-tenant-id: tenant1" \ + -d '{ + "email": "user@tenant1.com", + "password": "password123" + }' +``` + +## JWT Token Differences + +### Central Admin Token Payload +```json +{ + "sub": "user-id", + "email": "admin@platform.com", + "isCentralAdmin": true, + "iat": 1234567890, + "exp": 1234654290 +} +``` + +### Tenant User Token Payload +```json +{ + "sub": "user-id", + "email": "user@tenant1.com", + "iat": 1234567890, + "exp": 1234654290 +} +``` + +The `isCentralAdmin` flag in the JWT can be used to determine if the user is a central admin. + +## Database Schema + +### Central Database - `users` Table + +```sql +CREATE TABLE users ( + id VARCHAR(30) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + firstName VARCHAR(100), + lastName VARCHAR(100), + role VARCHAR(50) DEFAULT 'admin', + isActive BOOLEAN DEFAULT true, + createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, + updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +``` + +### Tenant Database - `users` Table + +Tenant databases have their own separate `users` table with similar structure but tenant-specific users. + +## Security Considerations + +1. **Separate Password Storage** - Central admin passwords are stored separately from tenant user passwords +2. **Role-Based Access** - Central admins have different permissions than tenant users +3. **JWT Identification** - The `isCentralAdmin` flag helps identify admin users +4. **Encryption** - All passwords are hashed using bcrypt with salt rounds + +## Common Use Cases + +### Platform Administration +- **Login as:** Central Admin +- **Can do:** + - Create/manage tenants + - View all tenant information + - Manage platform-wide settings + - Access tenant provisioning APIs + +### Tenant Management +- **Login as:** Tenant User +- **Can do:** + - Access tenant-specific data + - Manage records within the tenant + - Use tenant applications + - Limited to tenant scope + +## Troubleshooting + +### "Tenant ID is required" Error +- You're trying to login to tenant endpoint without tenant ID +- Solution: Either provide `x-tenant-id` header or use central admin login + +### "Invalid credentials" with Central Login +- Check that you're using the "Login as Central Admin" checkbox +- Verify the user exists in the central database +- Use the script to create a central admin if needed + +### "User already exists" +- A central admin with that email already exists +- Use a different email or reset the existing user's password + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ Frontend Login Form │ +│ ┌────────────────────────────────────┐ │ +│ │ ☑ Login as Central Admin │ │ +│ └────────────────────────────────────┘ │ +└──────────────┬──────────────────────────┘ + │ + ┌───────┴────────┐ + │ Checked? │ + └───────┬────────┘ + │ + ┌──────────┴──────────┐ + │ │ + ▼ ▼ +/api/central/auth/login /api/auth/login + │ │ + ▼ ▼ +Central Database Tenant Database +(users table) (users table) +``` + +## API Endpoints Summary + +| Endpoint | Purpose | Requires Tenant ID | Database | +|----------|---------|-------------------|----------| +| `POST /api/central/auth/login` | Central admin login | ❌ No | Central | +| `POST /api/central/auth/register` | Create central admin | ❌ No | Central | +| `POST /api/auth/login` | Tenant user login | ✅ Yes | Tenant | +| `POST /api/auth/register` | Create tenant user | ✅ Yes | Tenant | + +## Next Steps + +1. Create your first central admin user +2. Login with the central admin checkbox enabled +3. Access platform administration features +4. Manage tenants and platform settings + +For tenant management and provisioning, see [TENANT_MIGRATION_GUIDE.md](../TENANT_MIGRATION_GUIDE.md). diff --git a/CENTRAL_LOGIN.md b/CENTRAL_LOGIN.md new file mode 100644 index 0000000..7175d85 --- /dev/null +++ b/CENTRAL_LOGIN.md @@ -0,0 +1,130 @@ +# Central Admin Login + +## Overview + +The platform supports seamless authentication for both **tenant users** and **central administrators** using the same login endpoint. The system automatically determines which database to authenticate against based on the subdomain. + +## How It Works + +### Subdomain-Based Routing + +The authentication flow uses subdomain detection to determine the authentication context: + +1. **Central Subdomains** (e.g., `central.yourdomain.com`, `admin.yourdomain.com`) + - Authenticates against the **central database** + - Used for platform administrators + - Configured via `CENTRAL_SUBDOMAINS` environment variable + +2. **Tenant Subdomains** (e.g., `acme.yourdomain.com`, `client1.yourdomain.com`) + - Authenticates against the **tenant's database** + - Used for regular tenant users + - Each tenant has its own isolated database + +### Configuration + +Set the central subdomains in your `.env` file: + +```bash +# Comma-separated list of subdomains that access the central database +CENTRAL_SUBDOMAINS="central,admin" +``` + +### Implementation Details + +#### 1. Tenant Middleware (`tenant.middleware.ts`) + +The middleware extracts the subdomain from the request and: +- Checks if it matches a central subdomain +- If yes: Skips tenant resolution and attaches subdomain to request +- If no: Resolves the tenant ID from the subdomain and attaches it to request + +#### 2. Auth Service (`auth.service.ts`) + +The auth service has branching logic in `validateUser()` and `register()`: +- Checks if the subdomain is in the central list +- Routes to `validateCentralUser()` or normal tenant user validation +- Central users are authenticated against the `central` database +- Tenant users are authenticated against their tenant's database + +#### 3. Auth Controller (`auth.controller.ts`) + +The controller: +- Extracts subdomain from the request +- Validates tenant ID requirement (not needed for central subdomains) +- Passes subdomain to auth service for proper routing + +## Usage + +### Creating a Central Admin User + +```bash +cd backend +npm run create-central-admin +``` + +Follow the prompts to enter: +- Email +- Password +- First Name (optional) +- Last Name (optional) + +### Logging In as Central Admin + +1. Navigate to `central.yourdomain.com` (or whatever central subdomain you configured) +2. Enter your central admin email and password +3. You'll be authenticated against the central database + +**No special UI elements needed** - the system automatically detects the subdomain! + +### Logging In as Tenant User + +1. Navigate to `yourtenantslug.yourdomain.com` +2. Enter your tenant user credentials +3. You'll be authenticated against that tenant's database + +## Architecture Benefits + +✅ **Transparent to Frontend** - No need for special "login as admin" checkboxes or UI elements +✅ **Secure** - Central and tenant authentication are completely separated +✅ **Scalable** - Easy to add more central subdomains by updating environment variable +✅ **Clean Code** - Single auth controller/service with clear branching logic +✅ **Flexible** - Can be used for both development (localhost) and production + +## Local Development + +For local development, you can: + +1. **Use subdomain on localhost:** + ``` + central.localhost:3000 + acme.localhost:3000 + ``` + +2. **Use x-tenant-id header** (for tenant-specific requests): + ```bash + curl -H "x-tenant-id: acme-corp" http://localhost:3000/api/auth/login + ``` + +3. **For central admin, use central subdomain:** + ```bash + curl http://central.localhost:3000/api/auth/login + ``` + +## Database Schema + +### Central Database (`User` model) +- Stores platform administrators +- Prisma schema: `schema-central.prisma` +- Fields: id, email, password, firstName, lastName, isActive, createdAt, updatedAt + +### Tenant Database (`users` table) +- Stores tenant-specific users +- Knex migrations: `migrations/tenant/` +- Fields: id, email, password, firstName, lastName, isActive, created_at, updated_at + +## Security Considerations + +- Central admin credentials are never stored in tenant databases +- Tenant user credentials are never stored in the central database +- JWT tokens include user context (tenant ID or central admin flag) +- Subdomain validation prevents unauthorized access diff --git a/backend/.env.example b/backend/.env.example index caaefd8..6b0bc98 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -18,3 +18,6 @@ JWT_EXPIRES_IN="7d" # Application NODE_ENV="development" PORT="3000" + +# Central Admin Subdomains (comma-separated list of subdomains that access the central database) +CENTRAL_SUBDOMAINS="central,admin" diff --git a/backend/scripts/README.md b/backend/scripts/README.md index 21d7dd0..6d3ae90 100644 --- a/backend/scripts/README.md +++ b/backend/scripts/README.md @@ -1,8 +1,53 @@ -# Tenant Migration Scripts +# Tenant Migration & Admin Scripts -This directory contains scripts for managing database migrations across all tenants in the multi-tenant platform. +This directory contains scripts for managing database migrations across all tenants and creating admin users in the multi-tenant platform. -## Available Scripts +## Admin User Management + +### Create Central Admin User + +```bash +npm run create-central-admin +``` + +Creates an administrator user in the **central database**. Central admins can: +- Manage tenants (create, update, delete) +- Access platform-wide administration features +- View all tenant information +- Manage tenant provisioning + +**Interactive Mode:** +```bash +npm run create-central-admin +# You will be prompted for: +# - Email +# - Password +# - First Name (optional) +# - Last Name (optional) +# - Role (admin or superadmin) +``` + +**Non-Interactive Mode (using environment variables):** +```bash +EMAIL=admin@example.com PASSWORD=securepass123 FIRST_NAME=John LAST_NAME=Doe ROLE=superadmin npm run create-central-admin +``` + +**Logging In as Central Admin:** +1. Access the application using a central subdomain (e.g., `central.yourdomain.com` or `admin.yourdomain.com`) +2. Enter your central admin credentials +3. You'll be authenticated against the central database (not a tenant database) + +**Note:** The system automatically detects if you're logging in from a central subdomain based on the `CENTRAL_SUBDOMAINS` environment variable (defaults to `central,admin`). No special UI or configuration is needed on the frontend. + +### Create Tenant User + +For creating users within a specific tenant database, use: +```bash +npm run create-tenant-user +# (Note: This script may need to be created or already exists) +``` + +## Migration Scripts ### 1. Create a New Migration diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index c83028a..7f496a1 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, HttpCode, HttpStatus, + Req, } from '@nestjs/common'; import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; import { AuthService } from './auth.service'; @@ -40,17 +41,36 @@ class RegisterDto { export class AuthController { constructor(private authService: AuthService) {} + private isCentralSubdomain(subdomain: string): boolean { + const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(','); + return centralSubdomains.includes(subdomain); + } + @HttpCode(HttpStatus.OK) @Post('login') - async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) { - if (!tenantId) { - throw new UnauthorizedException('Tenant ID is required'); + async login( + @TenantId() tenantId: string, + @Body() loginDto: LoginDto, + @Req() req: any, + ) { + const subdomain = req.raw?.subdomain; + + console.log('subdomain:' + subdomain); + + console.log('CENTRAL_SUBDOMAINS:', process.env.CENTRAL_SUBDOMAINS); + + // If it's a central subdomain, tenantId is not required + if (!subdomain || !this.isCentralSubdomain(subdomain)) { + if (!tenantId) { + throw new UnauthorizedException('Tenant ID is required'); + } } const user = await this.authService.validateUser( tenantId, loginDto.email, loginDto.password, + subdomain, ); if (!user) { @@ -64,9 +84,15 @@ export class AuthController { async register( @TenantId() tenantId: string, @Body() registerDto: RegisterDto, + @Req() req: any, ) { - if (!tenantId) { - throw new UnauthorizedException('Tenant ID is required'); + const subdomain = req.raw?.subdomain; + + // If it's a central subdomain, tenantId is not required + if (!subdomain || !this.isCentralSubdomain(subdomain)) { + if (!tenantId) { + throw new UnauthorizedException('Tenant ID is required'); + } } const user = await this.authService.register( @@ -75,6 +101,7 @@ export class AuthController { registerDto.password, registerDto.firstName, registerDto.lastName, + subdomain, ); return user; diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c15929f..1188441 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; +import { getCentralPrisma } from '../prisma/central-prisma.service'; import * as bcrypt from 'bcrypt'; @Injectable() @@ -10,11 +11,24 @@ export class AuthService { private jwtService: JwtService, ) {} + private isCentralSubdomain(subdomain: string): boolean { + const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(','); + return centralSubdomains.includes(subdomain); + } + async validateUser( tenantId: string, email: string, password: string, + subdomain?: string, ): Promise { + + // Check if this is a central subdomain + if (subdomain && this.isCentralSubdomain(subdomain)) { + return this.validateCentralUser(email, password); + } + + // Otherwise, validate as tenant user const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); const user = await tenantDb('users') @@ -43,6 +57,31 @@ export class AuthService { return null; } + private async validateCentralUser( + email: string, + password: string, + ): Promise { + const centralPrisma = getCentralPrisma(); + + const user = await centralPrisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return null; + } + + if (await bcrypt.compare(password, user.password)) { + const { password: _, ...result } = user; + return { + ...result, + isCentralAdmin: true, + }; + } + + return null; + } + async login(user: any) { const payload = { sub: user.id, @@ -66,7 +105,14 @@ export class AuthService { password: string, firstName?: string, lastName?: string, + subdomain?: string, ) { + // Check if this is a central subdomain + if (subdomain && this.isCentralSubdomain(subdomain)) { + return this.registerCentralUser(email, password, firstName, lastName); + } + + // Otherwise, register as tenant user const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); const hashedPassword = await bcrypt.hash(password, 10); @@ -88,4 +134,28 @@ export class AuthService { const { password: _, ...result } = user; return result; } + + private async registerCentralUser( + email: string, + password: string, + firstName?: string, + lastName?: string, + ) { + const centralPrisma = getCentralPrisma(); + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await centralPrisma.user.create({ + data: { + email, + password: hashedPassword, + firstName: firstName || null, + lastName: lastName || null, + isActive: true, + }, + }); + + const { password: _, ...result } = user; + return result; + } } diff --git a/backend/src/tenant/tenant.middleware.ts b/backend/src/tenant/tenant.middleware.ts index 4a61263..5d4e40e 100644 --- a/backend/src/tenant/tenant.middleware.ts +++ b/backend/src/tenant/tenant.middleware.ts @@ -17,9 +17,14 @@ export class TenantMiddleware implements NestMiddleware { // Extract subdomain from hostname const host = req.headers.host || ''; const hostname = host.split(':')[0]; // Remove port if present - const parts = hostname.split('.'); + + // Check Origin header to get frontend subdomain (for API calls) + const origin = req.headers.origin as string; + const referer = req.headers.referer as string; + + let parts = hostname.split('.'); - this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}`); + this.logger.log(`Host header: ${host}, hostname: ${hostname}, origin: ${origin}, referer: ${referer}, parts: ${JSON.stringify(parts)}`); // For local development, accept x-tenant-id header let tenantId = req.headers['x-tenant-id'] as string; @@ -27,12 +32,26 @@ export class TenantMiddleware implements NestMiddleware { this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`); - // If x-tenant-id is explicitly provided, use it directly - if (tenantId) { - this.logger.log(`Using explicit x-tenant-id: ${tenantId}`); - (req as any).tenantId = tenantId; - next(); - return; + // Try to extract subdomain from Origin header first (for API calls from frontend) + if (origin) { + try { + const originUrl = new URL(origin); + const originHost = originUrl.hostname; + parts = originHost.split('.'); + this.logger.log(`Using Origin header hostname: ${originHost}, parts: ${JSON.stringify(parts)}`); + } catch (error) { + this.logger.warn(`Failed to parse origin: ${origin}`); + } + } else if (referer && !tenantId) { + // Fallback to Referer if no Origin + try { + const refererUrl = new URL(referer); + const refererHost = refererUrl.hostname; + parts = refererHost.split('.'); + this.logger.log(`Using Referer header hostname: ${refererHost}, parts: ${JSON.stringify(parts)}`); + } catch (error) { + this.logger.warn(`Failed to parse referer: ${referer}`); + } } // Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co") @@ -51,6 +70,36 @@ export class TenantMiddleware implements NestMiddleware { this.logger.log(`Extracted subdomain: ${subdomain}`); + // Always attach subdomain to request if present + if (subdomain) { + (req as any).subdomain = subdomain; + } + + // If x-tenant-id is explicitly provided, use it directly but still keep subdomain + if (tenantId) { + this.logger.log(`Using explicit x-tenant-id: ${tenantId}`); + (req as any).tenantId = tenantId; + next(); + return; + } + + // Always attach subdomain to request if present + if (subdomain) { + (req as any).subdomain = subdomain; + } + + // Check if this is a central subdomain + const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(','); + const isCentral = subdomain && centralSubdomains.includes(subdomain); + + // If it's a central subdomain, skip tenant resolution + if (isCentral) { + this.logger.log(`Central subdomain detected: ${subdomain}, skipping tenant resolution`); + (req as any).subdomain = subdomain; + next(); + return; + } + // Get tenant by subdomain if available if (subdomain) { try { @@ -72,9 +121,6 @@ export class TenantMiddleware implements NestMiddleware { if (tenantId) { // Attach tenant info to request object (req as any).tenantId = tenantId; - if (subdomain) { - (req as any).subdomain = subdomain; - } } else { this.logger.warn(`No tenant identified from host: ${hostname}`); }