722 lines
18 KiB
Markdown
722 lines
18 KiB
Markdown
# Authentication & Security Design
|
|
|
|
**Customer Portal - Security Architecture & Implementation**
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Security Overview](#security-overview)
|
|
2. [Authentication Architecture](#authentication-architecture)
|
|
3. [Token Management](#token-management)
|
|
4. [Authorization & Access Control](#authorization--access-control)
|
|
5. [Rate Limiting](#rate-limiting)
|
|
6. [Security Best Practices](#security-best-practices)
|
|
7. [Data Protection](#data-protection)
|
|
|
|
---
|
|
|
|
## Security Overview
|
|
|
|
The Customer Portal implements defense-in-depth security with multiple layers of protection, from secure authentication to PII redaction in logs.
|
|
|
|
### Security Principles
|
|
|
|
1. **Never expose sensitive data** [[memory:6689308]]
|
|
2. **Production-ready error handling** - No stack traces to users
|
|
3. **Principle of least privilege** - Minimal access rights
|
|
4. **Defense in depth** - Multiple security layers
|
|
5. **Audit everything** - Comprehensive logging
|
|
6. **Fail securely** - Secure defaults
|
|
|
|
---
|
|
|
|
## Authentication Architecture
|
|
|
|
### Authentication Flow
|
|
|
|
```
|
|
┌──────────────┐
|
|
│ Customer │
|
|
└──────┬───────┘
|
|
│
|
|
│ 1. Submit credentials
|
|
│
|
|
┌──────▼────────────────────────────┐
|
|
│ Portal │
|
|
│ POST /auth/login │
|
|
└──────┬────────────────────────────┘
|
|
│
|
|
│ 2. Forward to BFF
|
|
│
|
|
┌──────▼────────────────────────────┐
|
|
│ BFF Auth Module │
|
|
│ • Validate credentials │
|
|
│ • Check rate limits │
|
|
│ • Verify WHMCS link (optional) │
|
|
└──────┬────────────────────────────┘
|
|
│
|
|
│ 3. Issue tokens
|
|
│
|
|
┌──────▼────────────────────────────┐
|
|
│ Token Service │
|
|
│ • Access Token (JWT, 15 min) │
|
|
│ • Refresh Token (Redis, 7 days) │
|
|
└──────┬────────────────────────────┘
|
|
│
|
|
│ 4. Return tokens
|
|
│
|
|
┌──────▼────────────────────────────┐
|
|
│ Portal │
|
|
│ • Store Access Token (memory) │
|
|
│ • Store Refresh Token (cookie) │
|
|
└───────────────────────────────────┘
|
|
```
|
|
|
|
### Auth Module Structure
|
|
|
|
```
|
|
modules/auth/
|
|
├── application/
|
|
│ └── auth.facade.ts # Orchestrates auth workflows
|
|
├── presentation/
|
|
│ ├── http/
|
|
│ │ ├── auth.controller.ts # HTTP endpoints
|
|
│ │ ├── guards/
|
|
│ │ │ ├── global-auth.guard.ts
|
|
│ │ │ ├── auth-throttle.guard.ts
|
|
│ │ │ └── failed-login-throttle.guard.ts
|
|
│ │ └── interceptors/
|
|
│ │ └── login-result.interceptor.ts
|
|
│ └── strategies/
|
|
│ ├── local.strategy.ts # Password authentication
|
|
│ └── jwt.strategy.ts # JWT validation
|
|
├── infra/
|
|
│ ├── token/
|
|
│ │ ├── token.service.ts # Token issuance/rotation
|
|
│ │ └── token-blacklist.service.ts
|
|
│ ├── rate-limiting/
|
|
│ │ └── auth-rate-limit.service.ts
|
|
│ └── workflows/
|
|
│ ├── signup-workflow.service.ts
|
|
│ ├── password-workflow.service.ts
|
|
│ └── whmcs-link-workflow.service.ts
|
|
└── decorators/
|
|
└── public.decorator.ts # @Public() decorator
|
|
```
|
|
|
|
---
|
|
|
|
## Token Management
|
|
|
|
### Token Types
|
|
|
|
**Access Token (JWT)**:
|
|
- **Purpose**: Authorize API requests
|
|
- **Lifetime**: 15 minutes
|
|
- **Storage**: Memory (frontend)
|
|
- **Format**: JWT with user claims
|
|
|
|
```typescript
|
|
interface AccessTokenPayload {
|
|
sub: string; // User ID
|
|
email: string;
|
|
username: string;
|
|
iat: number; // Issued at
|
|
exp: number; // Expires at
|
|
}
|
|
```
|
|
|
|
**Refresh Token**:
|
|
- **Purpose**: Obtain new access tokens
|
|
- **Lifetime**: 7 days
|
|
- **Storage**: Redis (backend), httpOnly cookie (frontend)
|
|
- **Format**: Random string (UUIDv4)
|
|
- **Family tracking**: Token rotation for security
|
|
|
|
### Token Service Implementation
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class TokenService {
|
|
constructor(
|
|
private readonly jwtService: JwtService,
|
|
private readonly redis: Redis,
|
|
private readonly config: ConfigService
|
|
) {}
|
|
|
|
async issueTokens(userId: string): Promise<TokenPair> {
|
|
// Generate access token (JWT)
|
|
const accessToken = this.jwtService.sign({
|
|
sub: userId,
|
|
email: user.email,
|
|
username: user.username
|
|
}, {
|
|
expiresIn: '15m'
|
|
});
|
|
|
|
// Generate refresh token (random)
|
|
const refreshToken = randomUUID();
|
|
const tokenFamily = randomUUID();
|
|
|
|
// Store refresh token in Redis
|
|
await this.redis.setex(
|
|
`refresh:${refreshToken}`,
|
|
7 * 24 * 60 * 60, // 7 days
|
|
JSON.stringify({
|
|
userId,
|
|
family: tokenFamily,
|
|
createdAt: new Date().toISOString()
|
|
})
|
|
);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
async rotateRefreshToken(oldToken: string): Promise<TokenPair> {
|
|
// Get old token data
|
|
const data = await this.redis.get(`refresh:${oldToken}`);
|
|
|
|
if (!data) {
|
|
throw new UnauthorizedException('Invalid refresh token');
|
|
}
|
|
|
|
const { userId, family } = JSON.parse(data);
|
|
|
|
// Delete old token
|
|
await this.redis.del(`refresh:${oldToken}`);
|
|
|
|
// Check if family is compromised (reuse detected)
|
|
const familyCompromised = await this.checkFamilyCompromised(family);
|
|
|
|
if (familyCompromised) {
|
|
await this.revokeTokenFamily(family);
|
|
throw new UnauthorizedException('Token reuse detected');
|
|
}
|
|
|
|
// Issue new tokens with same family
|
|
return this.issueTokens(userId);
|
|
}
|
|
|
|
async revokeToken(token: string): Promise<void> {
|
|
await this.redis.del(`refresh:${token}`);
|
|
}
|
|
|
|
private async checkFamilyCompromised(family: string): Promise<boolean> {
|
|
return this.redis.exists(`family:compromised:${family}`) === 1;
|
|
}
|
|
|
|
private async revokeTokenFamily(family: string): Promise<void> {
|
|
// Mark family as compromised
|
|
await this.redis.setex(`family:compromised:${family}`, 60 * 60, '1');
|
|
|
|
// Delete all tokens in family
|
|
const keys = await this.redis.keys(`refresh:*`);
|
|
for (const key of keys) {
|
|
const data = await this.redis.get(key);
|
|
if (data && JSON.parse(data).family === family) {
|
|
await this.redis.del(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Token Blacklist
|
|
|
|
Revoked access tokens are blacklisted until they naturally expire:
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class TokenBlacklistService {
|
|
constructor(private readonly redis: Redis) {}
|
|
|
|
async blacklist(token: string, expiresAt: Date): Promise<void> {
|
|
const ttl = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
|
|
|
if (ttl > 0) {
|
|
await this.redis.setex(`blacklist:${token}`, ttl, '1');
|
|
}
|
|
}
|
|
|
|
async isBlacklisted(token: string): Promise<boolean> {
|
|
return (await this.redis.exists(`blacklist:${token}`)) === 1;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Authorization & Access Control
|
|
|
|
### Global Auth Guard
|
|
|
|
All endpoints require authentication by default:
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class GlobalAuthGuard extends AuthGuard('jwt') {
|
|
constructor(
|
|
private readonly reflector: Reflector,
|
|
private readonly blacklist: TokenBlacklistService
|
|
) {
|
|
super();
|
|
}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
// Check for @Public() decorator
|
|
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
|
context.getHandler(),
|
|
context.getClass()
|
|
]);
|
|
|
|
if (isPublic) {
|
|
return true;
|
|
}
|
|
|
|
// Validate JWT
|
|
const isValid = await super.canActivate(context);
|
|
|
|
if (!isValid) {
|
|
return false;
|
|
}
|
|
|
|
// Check blacklist
|
|
const request = context.switchToHttp().getRequest();
|
|
const token = this.extractToken(request);
|
|
|
|
if (await this.blacklist.isBlacklisted(token)) {
|
|
throw new UnauthorizedException('Token has been revoked');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private extractToken(request: any): string {
|
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
|
return type === 'Bearer' ? token : '';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Public Endpoints
|
|
|
|
Use `@Public()` decorator to exempt endpoints from authentication:
|
|
|
|
```typescript
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
@Post('login')
|
|
@Public()
|
|
async login(@Body() dto: LoginDto) {
|
|
return this.authFacade.login(dto.email, dto.password);
|
|
}
|
|
|
|
@Post('refresh')
|
|
@Public()
|
|
async refresh(@Req() req: Request) {
|
|
const refreshToken = req.cookies['refresh_token'];
|
|
return this.authFacade.refreshTokens(refreshToken);
|
|
}
|
|
|
|
@Post('logout')
|
|
async logout(@Req() req: Request) {
|
|
return this.authFacade.logout(req.user.sub, req.cookies['refresh_token']);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Row-Level Security
|
|
|
|
Users can only access their own data:
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class InvoiceService {
|
|
async getInvoices(userId: string): Promise<Invoice[]> {
|
|
// Automatically filtered by userId
|
|
return this.whmcsInvoiceService.getInvoices(parseInt(userId));
|
|
}
|
|
|
|
async getInvoice(userId: string, invoiceId: number): Promise<Invoice> {
|
|
const invoice = await this.whmcsInvoiceService.getInvoice(invoiceId);
|
|
|
|
// Verify ownership
|
|
if (invoice.userId !== parseInt(userId)) {
|
|
throw new ForbiddenException('Access denied');
|
|
}
|
|
|
|
return invoice;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Rate Limiting
|
|
|
|
### Rate Limit Configuration
|
|
|
|
```typescript
|
|
// Login attempts
|
|
LOGIN_RATE_LIMIT_LIMIT=5 // 5 attempts
|
|
LOGIN_RATE_LIMIT_TTL=900000 // 15 minutes
|
|
|
|
// Signup attempts
|
|
SIGNUP_RATE_LIMIT_LIMIT=3 // 3 attempts
|
|
SIGNUP_RATE_LIMIT_TTL=3600000 // 1 hour
|
|
|
|
// Token refresh
|
|
AUTH_REFRESH_RATE_LIMIT_LIMIT=10 // 10 attempts
|
|
AUTH_REFRESH_RATE_LIMIT_TTL=300000 // 5 minutes
|
|
|
|
// Password reset
|
|
PASSWORD_RESET_RATE_LIMIT_LIMIT=3 // 3 attempts
|
|
PASSWORD_RESET_RATE_LIMIT_TTL=3600000 // 1 hour
|
|
```
|
|
|
|
### Rate Limit Service
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class AuthRateLimitService {
|
|
constructor(private readonly redis: Redis) {}
|
|
|
|
async checkLoginAttempts(identifier: string): Promise<void> {
|
|
const key = `rate:login:${identifier}`;
|
|
const attempts = await this.redis.get(key);
|
|
|
|
if (attempts && parseInt(attempts) >= 5) {
|
|
const ttl = await this.redis.ttl(key);
|
|
throw new TooManyRequestsException(
|
|
`Too many login attempts. Try again in ${Math.ceil(ttl / 60)} minutes.`
|
|
);
|
|
}
|
|
}
|
|
|
|
async incrementLoginAttempts(identifier: string): Promise<void> {
|
|
const key = `rate:login:${identifier}`;
|
|
const current = await this.redis.incr(key);
|
|
|
|
if (current === 1) {
|
|
await this.redis.expire(key, 900); // 15 minutes
|
|
}
|
|
}
|
|
|
|
async clearLoginAttempts(identifier: string): Promise<void> {
|
|
await this.redis.del(`rate:login:${identifier}`);
|
|
}
|
|
|
|
async checkCaptchaRequired(identifier: string): Promise<boolean> {
|
|
const key = `rate:login:${identifier}`;
|
|
const attempts = await this.redis.get(key);
|
|
|
|
return attempts && parseInt(attempts) >= 3; // Show CAPTCHA after 3 attempts
|
|
}
|
|
}
|
|
```
|
|
|
|
### Failed Login Throttle Guard
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class FailedLoginThrottleGuard implements CanActivate {
|
|
constructor(private readonly rateLimitService: AuthRateLimitService) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const request = context.switchToHttp().getRequest();
|
|
const identifier = request.body.email || request.ip;
|
|
|
|
await this.rateLimitService.checkLoginAttempts(identifier);
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Security Best Practices
|
|
|
|
### Password Security
|
|
|
|
**Hashing**: Argon2id (OWASP recommended)
|
|
|
|
```typescript
|
|
import * as argon2 from 'argon2';
|
|
|
|
@Injectable()
|
|
export class PasswordService {
|
|
async hashPassword(password: string): Promise<string> {
|
|
return argon2.hash(password, {
|
|
type: argon2.argon2id,
|
|
memoryCost: 65536, // 64 MB
|
|
timeCost: 3, // 3 iterations
|
|
parallelism: 4 // 4 threads
|
|
});
|
|
}
|
|
|
|
async verifyPassword(hash: string, password: string): Promise<boolean> {
|
|
try {
|
|
return await argon2.verify(hash, password);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Password Requirements**:
|
|
- Minimum 8 characters
|
|
- At least one uppercase letter
|
|
- At least one lowercase letter
|
|
- At least one number
|
|
- At least one special character
|
|
|
|
```typescript
|
|
export const passwordSchema = z.string()
|
|
.min(8, 'Password must be at least 8 characters')
|
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
.regex(/[0-9]/, 'Password must contain at least one number')
|
|
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character');
|
|
```
|
|
|
|
### CSRF Protection
|
|
|
|
**Double Submit Cookie Pattern**:
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class CsrfService {
|
|
generateToken(): string {
|
|
return randomBytes(32).toString('hex');
|
|
}
|
|
|
|
validateToken(cookieToken: string, headerToken: string): boolean {
|
|
if (!cookieToken || !headerToken) {
|
|
return false;
|
|
}
|
|
|
|
return timingSafeEqual(
|
|
Buffer.from(cookieToken),
|
|
Buffer.from(headerToken)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Middleware
|
|
@Injectable()
|
|
export class CsrfMiddleware implements NestMiddleware {
|
|
constructor(private readonly csrfService: CsrfService) {}
|
|
|
|
use(req: Request, res: Response, next: NextFunction) {
|
|
// Generate token for new sessions
|
|
if (!req.cookies['csrf-secret']) {
|
|
const token = this.csrfService.generateToken();
|
|
res.cookie('csrf-secret', token, {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'strict'
|
|
});
|
|
}
|
|
|
|
// Validate token for mutating requests
|
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
|
const cookieToken = req.cookies['csrf-secret'];
|
|
const headerToken = req.headers['x-csrf-token'] as string;
|
|
|
|
if (!this.csrfService.validateToken(cookieToken, headerToken)) {
|
|
throw new ForbiddenException('Invalid CSRF token');
|
|
}
|
|
}
|
|
|
|
next();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
**Never expose sensitive information** [[memory:6689308]]:
|
|
|
|
```typescript
|
|
@Catch()
|
|
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
constructor(private readonly logger: Logger) {}
|
|
|
|
catch(exception: any, host: ArgumentsHost) {
|
|
const ctx = host.switchToHttp();
|
|
const response = ctx.getResponse();
|
|
const request = ctx.getRequest();
|
|
|
|
// Log full error details
|
|
this.logger.error('Request failed', {
|
|
url: request.url,
|
|
method: request.method,
|
|
error: exception.message,
|
|
stack: exception.stack
|
|
});
|
|
|
|
// Return user-friendly message
|
|
const status = exception instanceof HttpException
|
|
? exception.getStatus()
|
|
: 500;
|
|
|
|
const userMessage = exception instanceof HttpException
|
|
? exception.message
|
|
: 'An unexpected error occurred';
|
|
|
|
response.status(status).json({
|
|
success: false,
|
|
message: userMessage,
|
|
// NEVER include: stack, internal details, database errors
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Data Protection
|
|
|
|
### PII Redaction
|
|
|
|
**Automatic redaction in logs**:
|
|
|
|
```typescript
|
|
const sensitiveFields = [
|
|
'password',
|
|
'ssn',
|
|
'creditCard',
|
|
'cvv',
|
|
'accessToken',
|
|
'refreshToken'
|
|
];
|
|
|
|
function redactSensitiveData(data: any): any {
|
|
if (typeof data !== 'object' || data === null) {
|
|
return data;
|
|
}
|
|
|
|
const redacted = { ...data };
|
|
|
|
for (const key of Object.keys(redacted)) {
|
|
if (sensitiveFields.includes(key)) {
|
|
redacted[key] = '[REDACTED]';
|
|
} else if (typeof redacted[key] === 'object') {
|
|
redacted[key] = redactSensitiveData(redacted[key]);
|
|
}
|
|
}
|
|
|
|
return redacted;
|
|
}
|
|
|
|
// Logger setup
|
|
const logger = pino({
|
|
redact: {
|
|
paths: [
|
|
'password',
|
|
'req.headers.authorization',
|
|
'req.body.password',
|
|
'req.body.creditCard'
|
|
],
|
|
remove: true
|
|
}
|
|
});
|
|
```
|
|
|
|
### Secure Headers
|
|
|
|
```typescript
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", "data:", "https:"],
|
|
connectSrc: ["'self'"],
|
|
fontSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
mediaSrc: ["'self'"],
|
|
frameSrc: ["'none'"]
|
|
}
|
|
},
|
|
hsts: {
|
|
maxAge: 31536000,
|
|
includeSubDomains: true,
|
|
preload: true
|
|
}
|
|
}));
|
|
```
|
|
|
|
### Secure Cookies
|
|
|
|
```typescript
|
|
response.cookie('refresh_token', refreshToken, {
|
|
httpOnly: true, // Prevent XSS
|
|
secure: true, // HTTPS only
|
|
sameSite: 'strict', // Prevent CSRF
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
path: '/auth/refresh' // Limit scope
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Audit Trail
|
|
|
|
### Audit Logging
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class AuditService {
|
|
constructor(private readonly logger: Logger) {}
|
|
|
|
logAuthEvent(event: string, userId: string, metadata?: any) {
|
|
this.logger.info({
|
|
event,
|
|
userId,
|
|
timestamp: new Date().toISOString(),
|
|
...metadata
|
|
}, 'Auth event');
|
|
}
|
|
|
|
logAccessEvent(userId: string, resource: string, action: string) {
|
|
this.logger.info({
|
|
event: 'resource_access',
|
|
userId,
|
|
resource,
|
|
action,
|
|
timestamp: new Date().toISOString()
|
|
}, 'Resource access');
|
|
}
|
|
|
|
logSecurityEvent(event: string, metadata: any) {
|
|
this.logger.warn({
|
|
event,
|
|
timestamp: new Date().toISOString(),
|
|
...metadata
|
|
}, 'Security event');
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
await this.auditService.logAuthEvent('login_success', userId, { ip: request.ip });
|
|
await this.auditService.logSecurityEvent('rate_limit_exceeded', { ip: request.ip, endpoint: '/auth/login' });
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [System Architecture](./SYSTEM-ARCHITECTURE.md) - Overall system design
|
|
- [Integration & Data Flow](./INTEGRATION-DATAFLOW.md) - External system integration
|
|
|
|
---
|
|
|
|
**Last Updated**: October 2025
|
|
**Status**: Active - Production System
|
|
|