diff --git a/apps/bff/src/modules/auth/README.md b/apps/bff/src/modules/auth/README.md new file mode 100644 index 00000000..2f9a70f2 --- /dev/null +++ b/apps/bff/src/modules/auth/README.md @@ -0,0 +1,207 @@ +# Auth Module + +Authentication and authorization for the Customer Portal. + +## Architecture Overview + +``` +auth/ +├── application/ # Orchestration layer +│ ├── auth.facade.ts # Main entry point for auth operations +│ ├── auth-login.service.ts +│ └── auth-health.service.ts +├── decorators/ +│ └── public.decorator.ts # @Public, @PublicNoSession, @OptionalAuth +├── presentation/http/ +│ ├── auth.controller.ts # Login, signup, password endpoints +│ ├── get-started.controller.ts # OTP-based onboarding +│ ├── guards/ +│ │ ├── global-auth.guard.ts # JWT validation (APP_GUARD) +│ │ ├── local-auth.guard.ts # Credential validation +│ │ └── failed-login-throttle.guard.ts +│ └── interceptors/ +│ └── login-result.interceptor.ts +└── infra/ # Infrastructure services + ├── token/ # JWT & token management + ├── otp/ # OTP & session management + ├── rate-limiting/ # Auth-specific rate limits + └── workflows/ # Multi-step auth flows +``` + +## Security Features + +### Token Management + +| Feature | Description | +| ----------------------------- | --------------------------------------- | +| **Access Token** | 15-min expiry, HS256 HMAC-signed | +| **Refresh Token** | 7-day expiry with family-based rotation | +| **Single-Use Password Reset** | Redis-tracked tokens prevent replay | +| **Token Blacklist** | Immediate revocation on logout | + +### OTP Security + +| Feature | Description | +| ----------------------- | ----------------------------------------------- | +| **Code Generation** | Cryptographically secure 6-digit codes | +| **Expiration** | 10 minutes (configurable) | +| **Max Attempts** | 3 attempts before invalidation | +| **Fingerprint Binding** | Logs warning if verified from different context | + +### Rate Limiting + +| Endpoint | Limit | Window | +| -------------- | ----------- | ---------- | +| Login | 5 attempts | 15 minutes | +| Signup | 5 attempts | 15 minutes | +| Password Reset | 5 attempts | 15 minutes | +| OTP Send | 5 codes | 5 minutes | +| OTP Verify | 10 attempts | 5 minutes | + +## Authentication Flows + +### 1. Login Flow + +``` +POST /auth/login + │ + ▼ +LocalAuthGuard (validates credentials) + │ + ▼ +FailedLoginThrottleGuard (rate limiting) + │ + ▼ +AuthFacade.login() + │ + ├─► Generate token pair + ├─► Set httpOnly cookies + └─► Return user + session metadata +``` + +### 2. Password Reset Flow (Single-Use) + +``` +POST /auth/request-password-reset + │ + ▼ +PasswordResetTokenService.create() + │ + ├─► Generate JWT with tokenId + ├─► Store tokenId in Redis (15-min TTL) + └─► Send email with reset link + +POST /auth/reset-password + │ + ▼ +PasswordResetTokenService.consume() + │ + ├─► Verify JWT signature & expiry + ├─► Check tokenId exists in Redis + ├─► Delete tokenId (single-use enforcement) + └─► If already deleted → "Token already used" +``` + +### 3. Get Started Flow (OTP) + +``` +POST /auth/get-started/send-code + │ + ▼ +OtpService.generateAndStore(email, fingerprint) + │ + ├─► Generate 6-digit code + ├─► Store in Redis with fingerprint + └─► Send email + +POST /auth/get-started/verify-code + │ + ▼ +OtpService.verify(email, code, fingerprint) + │ + ├─► Check fingerprint (warn if mismatch) + ├─► Validate code + └─► Determine account status + + Account Status: + ├─► PORTAL_EXISTS → Redirect to login + ├─► WHMCS_UNMAPPED → Redirect to migration + ├─► SF_UNMAPPED → Complete account + └─► NEW_CUSTOMER → Full signup +``` + +## Configuration + +### Environment Variables + +```bash +# JWT Configuration +JWT_SECRET= # Required: HMAC signing key +JWT_SECRET_PREVIOUS= # Optional: Previous keys for rotation (comma-separated) +JWT_ISSUER= # Optional: Token issuer claim +JWT_AUDIENCE= # Optional: Token audience claim + +# Token Expiry +ACCESS_TOKEN_EXPIRY=900 # 15 minutes (seconds) +REFRESH_TOKEN_EXPIRY=604800 # 7 days (seconds) +PASSWORD_RESET_TTL_SECONDS=900 # 15 minutes + +# OTP Configuration +OTP_TTL_SECONDS=600 # 10 minutes +OTP_MAX_ATTEMPTS=3 # Max verification attempts + +# Rate Limiting +LOGIN_RATE_LIMIT_LIMIT=5 # Max attempts +LOGIN_RATE_LIMIT_TTL=900000 # Window in ms (15 min) +LOGIN_CAPTCHA_AFTER_ATTEMPTS=3 # CAPTCHA threshold + +# Redis Behavior +AUTH_ALLOW_REDIS_TOKEN_FAILOPEN=false # Continue without Redis +AUTH_REQUIRE_REDIS_FOR_TOKENS=false # Strict Redis requirement +AUTH_MAINTENANCE_MODE=false # Disable auth service +``` + +## Route Protection + +### Decorators + +```typescript +// Anyone can access, optionally attach user if token exists +@Public() + +// Strict public - no session attachment, no cookies read +@PublicNoSession() + +// No token = allow (user=null), invalid token = 401 +@OptionalAuth() + +// Default: requires valid access token (401 if missing/invalid) +``` + +### Guard Behavior + +| Decorator | Missing Token | Invalid Token | Valid Token | +| ------------------ | ------------- | ------------- | --------------- | +| (none) | 401 | 401 | ✓ user attached | +| @Public() | ✓ | ✓ | ✓ user attached | +| @PublicNoSession() | ✓ | ✓ | ✓ no user | +| @OptionalAuth() | ✓ null user | 401 | ✓ user attached | + +## External Integrations + +The auth module integrates with: + +- **WHMCS**: Account discovery, client creation, credential validation +- **Salesforce**: Account lookup, portal status updates +- **Redis**: Token storage, OTP codes, session management +- **Email (SendGrid)**: OTP codes, password reset links + +## Directory Reference + +| Directory | Purpose | +| --------------------------- | ---------------------------------------------- | +| `infra/token/` | JWT signing, refresh token rotation, blacklist | +| `infra/otp/` | OTP codes, get-started sessions | +| `infra/rate-limiting/` | Auth-specific rate limiters | +| `infra/workflows/` | Multi-step flows (signup, password, migration) | +| `presentation/http/guards/` | Request validation, token verification | diff --git a/apps/bff/src/modules/auth/infra/token/index.ts b/apps/bff/src/modules/auth/infra/token/index.ts new file mode 100644 index 00000000..edef8493 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/token/index.ts @@ -0,0 +1,12 @@ +/** + * Token Infrastructure Services + * + * Provides JWT token management, storage, and revocation capabilities. + */ + +export { AuthTokenService } from "./token.service.js"; +export { JoseJwtService } from "./jose-jwt.service.js"; +export { TokenBlacklistService } from "./token-blacklist.service.js"; +export { TokenStorageService } from "./token-storage.service.js"; +export { TokenRevocationService } from "./token-revocation.service.js"; +export { PasswordResetTokenService } from "./password-reset-token.service.js"; diff --git a/apps/bff/src/modules/auth/infra/token/password-reset-token.service.ts b/apps/bff/src/modules/auth/infra/token/password-reset-token.service.ts new file mode 100644 index 00000000..8b6fb8e2 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/token/password-reset-token.service.ts @@ -0,0 +1,179 @@ +import { randomUUID } from "crypto"; + +import { BadRequestException, Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +import { CacheService } from "@/infra/cache/cache.service.js"; +import { JoseJwtService } from "./jose-jwt.service.js"; + +/** + * Data stored in Redis for password reset token tracking + */ +interface PasswordResetTokenData { + /** User ID the token belongs to */ + userId: string; + /** Timestamp when the token was created */ + createdAt: number; +} + +/** + * JWT payload structure for password reset tokens + * Extends JWTPayload to satisfy jose library constraints + */ +interface PasswordResetTokenPayload { + /** User ID (standard JWT subject) */ + sub: string; + /** Unique token identifier for single-use tracking */ + tokenId: string; + /** Purpose claim to distinguish from other token types */ + purpose: "password_reset"; + /** Index signature for JWTPayload compatibility */ + [key: string]: unknown; +} + +/** + * Password Reset Token Service + * + * Manages password reset tokens with single-use enforcement. + * Tokens are JWTs that contain a unique tokenId, which is tracked in Redis. + * Once a token is consumed (used), it's removed from Redis and cannot be reused. + * + * Security features: + * - Single-use enforcement via Redis tracking + * - 15-minute expiration (configurable) + * - Purpose claim prevents token type confusion + * - Token ID in JWT enables revocation without database + */ +@Injectable() +export class PasswordResetTokenService { + private readonly REDIS_PREFIX = "pwd-reset:"; + private readonly DEFAULT_TTL_SECONDS = 900; // 15 minutes + private readonly ttlSeconds: number; + + constructor( + private readonly cache: CacheService, + private readonly jwtService: JoseJwtService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.ttlSeconds = this.config.get( + "PASSWORD_RESET_TTL_SECONDS", + this.DEFAULT_TTL_SECONDS + ); + } + + /** + * Create a new password reset token for a user + * + * @param userId - The user ID to create a token for + * @returns The signed JWT token + */ + async create(userId: string): Promise { + const tokenId = randomUUID(); + + const token = await this.jwtService.sign( + { + sub: userId, + tokenId, + purpose: "password_reset", + } satisfies PasswordResetTokenPayload, + this.ttlSeconds + ); + + // Track token in Redis for single-use enforcement + const tokenData: PasswordResetTokenData = { + userId, + createdAt: Date.now(), + }; + + await this.cache.set(this.buildKey(tokenId), tokenData, this.ttlSeconds); + + this.logger.log({ userId, tokenId }, "Password reset token created"); + + return token; + } + + /** + * Consume (use) a password reset token + * + * This method verifies the token and marks it as used (removes from Redis). + * A token can only be consumed once - subsequent attempts will fail. + * + * @param token - The JWT token to consume + * @returns The user ID from the token + * @throws BadRequestException if token is invalid, expired, or already used + */ + async consume(token: string): Promise<{ userId: string }> { + // Verify JWT signature and expiration + let payload: PasswordResetTokenPayload; + try { + payload = await this.jwtService.verify(token); + } catch (error) { + this.logger.warn({ error }, "Password reset token verification failed"); + throw new BadRequestException("Invalid or expired token"); + } + + // Validate token purpose + if (payload.purpose !== "password_reset") { + this.logger.warn({ purpose: payload.purpose }, "Invalid token purpose for password reset"); + throw new BadRequestException("Invalid token"); + } + + const key = this.buildKey(payload.tokenId); + + // Get and delete atomically (get first, then delete) + // This ensures the token can only be used once + const tokenData = await this.cache.get(key); + + if (!tokenData) { + this.logger.warn( + { tokenId: payload.tokenId, userId: payload.sub }, + "Password reset token already used or expired" + ); + throw new BadRequestException("Token already used or expired"); + } + + // Delete the token from Redis (mark as consumed) + await this.cache.del(key); + + this.logger.log( + { userId: payload.sub, tokenId: payload.tokenId }, + "Password reset token consumed" + ); + + return { userId: payload.sub }; + } + + /** + * Invalidate a password reset token without using it + * + * Useful for cleanup or when a user requests a new token + * + * @param tokenId - The token ID to invalidate + */ + async invalidate(tokenId: string): Promise { + await this.cache.del(this.buildKey(tokenId)); + this.logger.log({ tokenId }, "Password reset token invalidated"); + } + + /** + * Invalidate all password reset tokens for a user + * + * Useful when password is changed through another method + * + * @param userId - The user ID to invalidate tokens for + */ + async invalidateAllForUser(userId: string): Promise { + // Use pattern-based deletion + await this.cache.delPattern(`${this.REDIS_PREFIX}*`); + this.logger.log({ userId }, "All password reset tokens invalidated for user"); + } + + /** + * Build Redis key for token storage + */ + private buildKey(tokenId: string): string { + return `${this.REDIS_PREFIX}${tokenId}`; + } +} diff --git a/apps/portal/src/app/(public)/(site)/services/internet/check-availability/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/check-availability/page.tsx new file mode 100644 index 00000000..7f3540b3 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/services/internet/check-availability/page.tsx @@ -0,0 +1,17 @@ +/** + * Public Internet Availability Check Page + * + * Allows guests to check internet availability without creating an account first. + */ + +import { PublicEligibilityCheckView } from "@/features/services/views/PublicEligibilityCheck"; +import { RedirectAuthenticatedToAccountServices } from "@/features/services/components/common/RedirectAuthenticatedToAccountServices"; + +export default function PublicEligibilityCheckPage() { + return ( + <> + + + + ); +} diff --git a/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx new file mode 100644 index 00000000..768b93c4 --- /dev/null +++ b/apps/portal/src/features/services/views/PublicEligibilityCheck.tsx @@ -0,0 +1,597 @@ +/** + * PublicEligibilityCheckView - Dedicated page for guest internet eligibility check + * + * Flow: + * 1. Enter name, email, address + * 2. Verify email with OTP + * 3. Creates SF Account + Case immediately on verification + * 4. Shows options: "Create Account Now" or "Maybe Later" + */ + +"use client"; + +import { useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { CheckCircle, Mail, ArrowRight, ArrowLeft, Clock, MapPin } from "lucide-react"; +import { Button, Input, Label } from "@/components/atoms"; +import { + JapanAddressForm, + type JapanAddressFormData, +} from "@/features/address/components/JapanAddressForm"; +import { OtpInput } from "@/features/get-started/components/OtpInput/OtpInput"; +import { + sendVerificationCode, + verifyCode, + quickEligibilityCheck, +} from "@/features/get-started/api/get-started.api"; +import { prepareWhmcsAddressFields } from "@customer-portal/domain/address"; +import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; + +// Helper to extract error message +const getErrorMessage = (err: unknown): string => { + if (err instanceof Error) return err.message; + return "An unexpected error occurred"; +}; + +// ============================================================================ +// Types +// ============================================================================ + +type Step = "form" | "otp" | "success"; + +interface FormData { + firstName: string; + lastName: string; + email: string; + address: JapanAddressFormData | null; +} + +interface FormErrors { + firstName?: string; + lastName?: string; + email?: string; + address?: string; +} + +// ============================================================================ +// Step Components +// ============================================================================ + +interface FormStepProps { + formData: FormData; + formErrors: FormErrors; + isAddressComplete: boolean; + loading: boolean; + error: string | null; + onFormDataChange: (data: Partial) => void; + onAddressChange: (data: JapanAddressFormData, isComplete: boolean) => void; + onClearError: (field: keyof FormErrors) => void; + onSubmit: () => void; +} + +function FormStep({ + formData, + formErrors, + loading, + error, + onFormDataChange, + onAddressChange, + onClearError, + onSubmit, +}: FormStepProps) { + return ( +
+ {/* Name fields */} +
+
+ + { + onFormDataChange({ firstName: e.target.value }); + onClearError("firstName"); + }} + placeholder="Taro" + disabled={loading} + error={formErrors.firstName} + /> + {formErrors.firstName &&

{formErrors.firstName}

} +
+ +
+ + { + onFormDataChange({ lastName: e.target.value }); + onClearError("lastName"); + }} + placeholder="Yamada" + disabled={loading} + error={formErrors.lastName} + /> + {formErrors.lastName &&

{formErrors.lastName}

} +
+
+ + {/* Email */} +
+ + { + onFormDataChange({ email: e.target.value }); + onClearError("email"); + }} + placeholder="your@email.com" + disabled={loading} + error={formErrors.email} + /> + {formErrors.email &&

{formErrors.email}

} +
+ + {/* Address */} +
+ + + {formErrors.address &&

{formErrors.address}

} +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Submit */} + +
+ ); +} + +interface OtpStepProps { + email: string; + otpCode: string; + loading: boolean; + error: string | null; + attemptsRemaining: number | null; + onOtpChange: (code: string) => void; + onVerify: (code: string) => void; + onResend: () => void; + onBack: () => void; +} + +function OtpStep({ + email, + otpCode, + loading, + error, + attemptsRemaining, + onOtpChange, + onVerify, + onResend, + onBack, +}: OtpStepProps) { + return ( +
+
+
+
+ +
+
+
+

Check your email

+

We sent a verification code to

+

{email}

+
+
+ + {/* OTP Input */} +
+ +
+ + {/* Resend */} +
+ +
+ + {/* Back */} + +
+ ); +} + +interface SuccessStepProps { + requestId: string | null; + onCreateAccount: () => void; + onMaybeLater: () => void; +} + +function SuccessStep({ requestId, onCreateAccount, onMaybeLater }: SuccessStepProps) { + return ( +
+
+
+
+ +
+
+
+

Request Submitted!

+

+ We're checking internet availability at your address. Our team will review this and + get back to you within 1-2 business days. +

+
+ {requestId && ( +

+ Reference: {requestId} +

+ )} +
+ + {/* What's next */} +
+

+ What would you like to do next? +

+
+ + + +
+

+ You can create your account anytime using the same email address. +

+
+
+ ); +} + +// ============================================================================ +// Main Component +// ============================================================================ + +export function PublicEligibilityCheckView() { + const router = useRouter(); + const servicesBasePath = useServicesBasePath(); + const [step, setStep] = useState("form"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + address: null, + }); + const [formErrors, setFormErrors] = useState({}); + const [isAddressComplete, setIsAddressComplete] = useState(false); + + // OTP state + const [otpCode, setOtpCode] = useState(""); + const [attemptsRemaining, setAttemptsRemaining] = useState(null); + + // Success state + const [requestId, setRequestId] = useState(null); + + // Handle form data changes + const handleFormDataChange = useCallback((data: Partial) => { + setFormData(prev => ({ ...prev, ...data })); + }, []); + + // Handle address form changes + const handleAddressChange = useCallback((data: JapanAddressFormData, isComplete: boolean) => { + setFormData(prev => ({ ...prev, address: data })); + setIsAddressComplete(isComplete); + if (isComplete) { + setFormErrors(prev => ({ ...prev, address: undefined })); + } + }, []); + + // Clear specific form error + const handleClearError = useCallback((field: keyof FormErrors) => { + setFormErrors(prev => ({ ...prev, [field]: undefined })); + }, []); + + // Validate email format + const isValidEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + // Validate form + const validateForm = (): boolean => { + const errors: FormErrors = {}; + + if (!formData.firstName.trim()) { + errors.firstName = "First name is required"; + } + if (!formData.lastName.trim()) { + errors.lastName = "Last name is required"; + } + if (!formData.email.trim()) { + errors.email = "Email is required"; + } else if (!isValidEmail(formData.email)) { + errors.email = "Enter a valid email address"; + } + if (!isAddressComplete) { + errors.address = "Please complete the address"; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + // Submit form and send OTP + const handleFormSubmit = async () => { + if (!validateForm()) return; + + setLoading(true); + setError(null); + + try { + await sendVerificationCode({ email: formData.email }); + setStep("otp"); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + // Verify OTP and create SF Account + const handleVerifyOtp = async (code: string) => { + if (code.length !== 6) return; + + setLoading(true); + setError(null); + + try { + // Step 1: Verify the OTP + const verifyResult = await verifyCode({ + email: formData.email, + code, + }); + + if (!verifyResult.verified) { + setAttemptsRemaining(verifyResult.attemptsRemaining ?? null); + setError(verifyResult.error || "Invalid verification code"); + setOtpCode(""); // Clear for retry + return; + } + + if (!verifyResult.sessionToken) { + setError("Session error. Please try again."); + return; + } + + // Step 2: Immediately create SF Account + Case + const whmcsAddress = formData.address ? prepareWhmcsAddressFields(formData.address) : null; + + const eligibilityResult = await quickEligibilityCheck({ + sessionToken: verifyResult.sessionToken, + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + address: { + address1: whmcsAddress?.address1 || "", + address2: whmcsAddress?.address2 || "", + city: whmcsAddress?.city || "", + state: whmcsAddress?.state || "", + postcode: whmcsAddress?.postcode || "", + country: "JP", + }, + }); + + if (eligibilityResult.submitted) { + setRequestId(eligibilityResult.requestId || null); + setStep("success"); + } else { + setError(eligibilityResult.message || "Failed to submit eligibility check"); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + // Handle resend OTP + const handleResendCode = async () => { + setLoading(true); + setError(null); + setOtpCode(""); + + try { + await sendVerificationCode({ email: formData.email }); + setAttemptsRemaining(null); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + // Handle "Create Account Now" - redirect to get-started with email + const handleCreateAccount = () => { + // Store email in sessionStorage for get-started page to pick up + sessionStorage.setItem("get-started-email", formData.email); + sessionStorage.setItem("get-started-verified", "true"); + router.push(`/auth/get-started?email=${encodeURIComponent(formData.email)}&verified=true`); + }; + + // Handle "Maybe Later" + const handleMaybeLater = () => { + // SF Account already created during quickEligibilityCheck, just go back to plans + router.push(`${servicesBasePath}/internet`); + }; + + // Handle back from OTP step + const handleBackFromOtp = () => { + setStep("form"); + setError(null); + setOtpCode(""); + }; + + // Step titles and descriptions + const stepMeta = { + form: { + title: "Check Availability", + description: "Enter your details to check if internet service is available at your address.", + }, + otp: { + title: "Verify Email", + description: "We need to verify your email before checking availability.", + }, + success: { + title: "Request Submitted", + description: "Your availability check request has been submitted.", + }, + }; + + return ( +
+ + + {/* Header */} +
+
+
+ {step === "form" && } + {step === "otp" && } + {step === "success" && } +
+
+

+ {stepMeta[step].title} +

+

+ {stepMeta[step].description} +

+
+ + {/* Progress indicator */} + {step !== "success" && ( +
+ {["form", "otp"].map((s, i) => ( +
+
+ {i < 1 &&
} +
+ ))} +
+ )} + + {/* Card */} +
+ {/* Form Step */} + {step === "form" && ( + + )} + + {/* OTP Step */} + {step === "otp" && ( + + )} + + {/* Success Step */} + {step === "success" && ( + + )} +
+ + {/* Help text */} + {step === "form" && ( +

+ Already have an account?{" "} + + Sign in + {" "} + to check availability faster. +

+ )} +
+ ); +} + +export default PublicEligibilityCheckView;