18 KiB
18 KiB
Authentication & Security Design
Customer Portal - Security Architecture & Implementation
Table of Contents
- Security Overview
- Authentication Architecture
- Token Management
- Authorization & Access Control
- Rate Limiting
- Security Best Practices
- 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
- Never expose sensitive data memory:6689308
- Production-ready error handling - No stack traces to users
- Principle of least privilege - Minimal access rights
- Defense in depth - Multiple security layers
- Audit everything - Comprehensive logging
- 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
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
@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:
@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:
@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:
@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:
@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
// 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
@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
@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)
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
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:
@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:
@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:
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
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
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
@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 - Overall system design
- Integration & Data Flow - External system integration
Last Updated: October 2025
Status: Active - Production System