Assist_Design/docs/AUTHENTICATION-SECURITY.md

18 KiB

Authentication & Security Design

Customer Portal - Security Architecture & Implementation


Table of Contents

  1. Security Overview
  2. Authentication Architecture
  3. Token Management
  4. Authorization & Access Control
  5. Rate Limiting
  6. Security Best Practices
  7. 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
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' });


Last Updated: October 2025
Status: Active - Production System