diff --git a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts index 37c7d5fd..b1661faf 100644 --- a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts @@ -177,9 +177,13 @@ export class VerificationWorkflowService { }; } - private async determineAccountStatus( - email: string - ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { + private async determineAccountStatus(email: string): Promise<{ + status: AccountStatus; + sfAccountId?: string; + whmcsClientId?: number; + whmcsFirstName?: string; + whmcsLastName?: string; + }> { // Check Portal user first const portalUser = await this.usersService.findByEmailInternal(email); if (portalUser) { @@ -196,7 +200,12 @@ export class VerificationWorkflowService { if (mapping) { return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; } - return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id }; + return { + status: ACCOUNT_STATUS.WHMCS_UNMAPPED, + whmcsClientId: whmcsClient.id, + ...(whmcsClient.firstname && { whmcsFirstName: whmcsClient.firstname }), + ...(whmcsClient.lastname && { whmcsLastName: whmcsClient.lastname }), + }; } // Check Salesforce account @@ -214,8 +223,20 @@ export class VerificationWorkflowService { private getPrefillData( email: string, - accountStatus: { status: AccountStatus; sfAccountId?: string } + accountStatus: { + status: AccountStatus; + sfAccountId?: string; + whmcsFirstName?: string; + whmcsLastName?: string; + } ): VerifyCodeResponse["prefill"] { + if (accountStatus.status === ACCOUNT_STATUS.WHMCS_UNMAPPED) { + return { + email, + ...(accountStatus.whmcsFirstName && { firstName: accountStatus.whmcsFirstName }), + ...(accountStatus.whmcsLastName && { lastName: accountStatus.whmcsLastName }), + }; + } if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) { return { email }; } diff --git a/apps/portal/src/features/account/components/PasswordChangeCard.tsx b/apps/portal/src/features/account/components/PasswordChangeCard.tsx index f7d94a78..ddc33b35 100644 --- a/apps/portal/src/features/account/components/PasswordChangeCard.tsx +++ b/apps/portal/src/features/account/components/PasswordChangeCard.tsx @@ -76,8 +76,7 @@ export function PasswordChangeCard({

- Password must be at least 8 characters and include uppercase, lowercase, number, and special - character. + Password must be at least 8 characters and include uppercase, lowercase, and a number.

); diff --git a/apps/portal/src/features/auth/components/PasswordRequirements.tsx b/apps/portal/src/features/auth/components/PasswordRequirements.tsx index 62c5a2ba..7f3c2a3f 100644 --- a/apps/portal/src/features/auth/components/PasswordRequirements.tsx +++ b/apps/portal/src/features/auth/components/PasswordRequirements.tsx @@ -30,7 +30,7 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi if (showHint) { return (

- At least 8 characters with uppercase, lowercase, numbers, and a special character + At least 8 characters with uppercase, lowercase, and numbers

); } @@ -41,7 +41,6 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi - ); } diff --git a/apps/portal/src/features/auth/hooks/usePasswordValidation.ts b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts index 149363a5..94e440fb 100644 --- a/apps/portal/src/features/auth/hooks/usePasswordValidation.ts +++ b/apps/portal/src/features/auth/hooks/usePasswordValidation.ts @@ -5,7 +5,6 @@ export interface PasswordChecks { hasUppercase: boolean; hasLowercase: boolean; hasNumber: boolean; - hasSpecialChar: boolean; } export interface PasswordValidation { @@ -20,7 +19,6 @@ export function validatePasswordRules(password: string): string | undefined { if (!/[A-Z]/.test(password)) return "Password must contain an uppercase letter"; if (!/[a-z]/.test(password)) return "Password must contain a lowercase letter"; if (!/[0-9]/.test(password)) return "Password must contain a number"; - if (!/[^A-Za-z0-9]/.test(password)) return "Password must contain a special character"; return undefined; } @@ -31,15 +29,10 @@ export function usePasswordValidation(password: string): PasswordValidation { hasUppercase: /[A-Z]/.test(password), hasLowercase: /[a-z]/.test(password), hasNumber: /[0-9]/.test(password), - hasSpecialChar: /[^A-Za-z0-9]/.test(password), }; const isValid = - checks.minLength && - checks.hasUppercase && - checks.hasLowercase && - checks.hasNumber && - checks.hasSpecialChar; + checks.minLength && checks.hasUppercase && checks.hasLowercase && checks.hasNumber; const error = validatePasswordRules(password); return { checks, isValid, error }; diff --git a/docs/plans/2026-03-02-get-started-login-handoff-plan.md b/docs/plans/2026-03-02-get-started-login-handoff-plan.md index 5916aeb4..e8c8b0cf 100644 --- a/docs/plans/2026-03-02-get-started-login-handoff-plan.md +++ b/docs/plans/2026-03-02-get-started-login-handoff-plan.md @@ -8,6 +8,16 @@ **Tech Stack:** NestJS (BFF), Zod (validation), Redis (sessions), Argon2 (password), XState (frontend state), React (portal) +**Security Hardening (applied throughout):** + +- Session tokens never logged in full — only last 4 chars (`sessionTokenSuffix`) to prevent log exfiltration +- All auth error responses use a generic "Login failed" message (specific reasons in server logs only) to prevent state enumeration +- `check-session` returns masked email (`ba***@example.com`), not the full address, to limit information disclosure +- Password validation runs BEFORE session consumption — wrong passwords don't burn the one-time session, allowing retries (account lockout in `AuthLoginService` protects against brute-force) +- `gs_verified` cookie cleared on success only — preserved on wrong password so user can retry (stale cookies auto-expire via 5min `maxAge` and `check-session` cleanup) +- Email removed from redirect URL — login page gets it from `check-session` cookie response, avoiding leakage into browser history, logs, and referrer headers +- `"login"` added to `SessionData.usedFor` union type for session tracking + --- ### Task 1: Add domain schemas and types for login handoff @@ -45,8 +55,8 @@ export const getStartedLoginRequestSchema = z.object({ export const checkSessionResponseSchema = z.object({ /** Whether a valid verified session exists */ valid: z.boolean(), - /** Email from the session (only present when valid) */ - email: z.string().optional(), + /** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */ + maskedEmail: z.string().optional(), }); ``` @@ -67,6 +77,14 @@ Add types after `MigrateWhmcsAccountRequest` (after line 94): ```typescript export type GetStartedLoginRequest = z.infer; export type CheckSessionResponse = z.infer; + +/** Mask an email address for safe display (e.g. "ba***@example.com") */ +export function maskEmail(email: string): string { + const [local, domain] = email.split("@"); + if (!local || !domain) return "***@***"; + const visible = local.slice(0, Math.min(2, local.length)); + return `${visible}${"*".repeat(Math.max(0, local.length - 2))}@${domain}`; +} ``` **Step 3: Add exports to `packages/domain/get-started/index.ts`** @@ -76,6 +94,7 @@ Add to the contract exports section (after `type GetStartedError` on line 37): ```typescript type GetStartedLoginRequest, type CheckSessionResponse, + maskEmail, ``` Add to the schemas export section (after `getStartedSessionSchema` on line 64): @@ -108,96 +127,14 @@ git commit -m "feat(domain): add login handoff schemas for portal_exists OTP byp **Step 1: Create the workflow service** +> **Note:** This initial version uses `AuthOrchestrator` which creates a circular dependency. See the **revised version in Task 3 Step 1** which replaces it with `GenerateAuthResultStep`. The revised version is the one to implement. + This service validates a verified get-started session, checks the user's password, and completes the login. It reuses `AuthLoginService` for password validation and `AuthOrchestrator.completeLogin()` for token generation. +**Prerequisite:** Add `"login"` to the `usedFor` union type in `apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts` (line 25): + ```typescript -/** - * Get-Started Login Workflow - * - * Handles the login-via-verified-session flow for portal_exists users. - * After OTP verification in the get-started flow, this service validates - * the user's password and completes login without requiring a second OTP. - */ -import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started"; - -import { GetStartedSessionService } from "../otp/get-started-session.service.js"; -import { AuthLoginService } from "../../application/auth-login.service.js"; -import { AuthOrchestrator } from "../../application/auth-orchestrator.service.js"; -import type { AuthResultInternal } from "../../auth.types.js"; - -@Injectable() -export class GetStartedLoginWorkflowService { - constructor( - private readonly sessionService: GetStartedSessionService, - private readonly loginService: AuthLoginService, - private readonly authOrchestrator: AuthOrchestrator, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Validate a verified get-started session and check password. - * Returns auth tokens on success. - * - * @param sessionToken - From the gs_verified cookie - * @param password - User's portal password - */ - async execute(sessionToken: string, password: string): Promise { - // 1. Acquire lock and mark session as used (prevents replay) - const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); - - if (!lockResult.success) { - this.logger.warn( - { sessionToken, reason: lockResult.reason }, - "Login handoff session invalid" - ); - throw new UnauthorizedException("Session is invalid or has already been used"); - } - - const session = lockResult.session; - - // 2. Verify the session was for a portal_exists account - if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { - this.logger.warn( - { sessionToken, accountStatus: session.accountStatus }, - "Login handoff session has wrong account status" - ); - throw new UnauthorizedException("Session is not valid for login"); - } - - // 3. Validate password via existing login service (handles lockout, audit, etc.) - const validatedUser = await this.loginService.validateUser(session.email, password); - - if (!validatedUser) { - throw new UnauthorizedException("Invalid password"); - } - - // 4. Complete login (generates tokens, updates lastLoginAt, audit log) - this.logger.log({ email: session.email }, "Login handoff successful — completing login"); - return this.authOrchestrator.completeLogin(validatedUser); - } - - /** - * Check if a session token is valid for login handoff. - * Does NOT consume the session. - */ - async checkSession( - sessionToken: string - ): Promise<{ valid: true; email: string } | { valid: false }> { - const session = await this.sessionService.validateVerifiedSession(sessionToken); - - if (!session) { - return { valid: false }; - } - - if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { - return { valid: false }; - } - - return { valid: true, email: session.email }; - } -} +usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login"; ``` **Step 2: Verify types compile** @@ -275,7 +212,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; ```typescript import { Injectable, Inject, UnauthorizedException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { ACCOUNT_STATUS } from "@customer-portal/domain/get-started"; +import { ACCOUNT_STATUS, maskEmail } from "@customer-portal/domain/get-started"; import { GetStartedSessionService } from "../otp/get-started-session.service.js"; import { AuthLoginService } from "../../application/auth-login.service.js"; @@ -296,46 +233,61 @@ export class GetStartedLoginWorkflowService { ) {} async execute(sessionToken: string, password: string): Promise { - // 1. Acquire lock and mark session as used (prevents replay) - const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); + // 1. Validate session exists and is verified (without consuming it) + const session = await this.sessionService.validateVerifiedSession(sessionToken); - if (!lockResult.success) { + if (!session) { this.logger.warn( - { sessionToken, reason: lockResult.reason }, - "Login handoff session invalid" + { sessionTokenSuffix: sessionToken.slice(-4) }, + "Login handoff session invalid or expired" ); - throw new UnauthorizedException("Session is invalid or has already been used"); + throw new UnauthorizedException("Login failed"); } - const session = lockResult.session; - // 2. Verify the session was for a portal_exists account if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { this.logger.warn( - { sessionToken, accountStatus: session.accountStatus }, + { sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus }, "Login handoff session has wrong account status" ); - throw new UnauthorizedException("Session is not valid for login"); + throw new UnauthorizedException("Login failed"); } - // 3. Validate password (handles lockout, audit, failed attempt tracking) + // 3. Validate password FIRST (handles lockout, audit, failed attempt tracking) + // This does NOT consume the session — wrong passwords allow retries. + // Brute-force protection comes from AuthLoginService's account lockout. const validatedUser = await this.loginService.validateUser(session.email, password); if (!validatedUser) { - throw new UnauthorizedException("Invalid password"); + this.logger.warn( + { sessionTokenSuffix: sessionToken.slice(-4), email: session.email }, + "Login handoff password validation failed" + ); + throw new UnauthorizedException("Login failed"); } - // 4. Update lastLoginAt and reset failed attempts + // 4. Password correct — now consume the session atomically (prevents replay) + const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login"); + + if (!lockResult.success) { + this.logger.warn( + { sessionTokenSuffix: sessionToken.slice(-4), reason: lockResult.reason }, + "Login handoff session consumed during login (race condition)" + ); + throw new UnauthorizedException("Login failed"); + } + + // 5. Update lastLoginAt and reset failed attempts await this.usersService.update(validatedUser.id, { lastLoginAt: new Date(), failedLoginAttempts: 0, lockedUntil: null, }); - // 5. Generate auth tokens + // 6. Generate auth tokens const result = await this.generateAuthResult.execute(validatedUser.id); - // 6. Audit log + // 7. Audit log await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, { email: session.email, method: "get_started_handoff", @@ -345,16 +297,21 @@ export class GetStartedLoginWorkflowService { return result; } + /** + * Check if a session token is valid for login handoff. + * Does NOT consume the session. + * Returns masked email to limit information disclosure. + */ async checkSession( sessionToken: string - ): Promise<{ valid: true; email: string } | { valid: false }> { + ): Promise<{ valid: true; maskedEmail: string } | { valid: false }> { const session = await this.sessionService.validateVerifiedSession(sessionToken); if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) { return { valid: false }; } - return { valid: true, email: session.email }; + return { valid: true, maskedEmail: maskEmail(session.email) }; } } ``` @@ -431,13 +388,16 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl ```typescript /** - * Check if a valid get-started handoff session exists - * Used by the login page to detect OTP-bypass mode + * Check if a valid get-started handoff session exists. + * Used by the login page to detect OTP-bypass mode. + * Returns masked email to limit information disclosure. + * Clears the stale cookie if the session is no longer valid. */ @Public() @Get("check-session") async checkSession( - @Req() req: Request + @Req() req: Request, + @Res({ passthrough: true }) res: Response ): Promise { const sessionToken = req.cookies?.["gs_verified"] as string | undefined; @@ -445,7 +405,14 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl return { valid: false }; } - return this.loginWorkflow.checkSession(sessionToken); + const result = await this.loginWorkflow.checkSession(sessionToken); + + // Clear stale cookie if session is no longer valid + if (!result.valid) { + res.clearCookie("gs_verified", { path: "/api/auth/get-started" }); + } + + return result; } /** @@ -454,6 +421,11 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl * For portal_exists users after OTP verification in get-started flow. * Session token comes from the gs_verified HttpOnly cookie. * Skips login OTP since email was already verified via get-started OTP. + * + * Security: all errors return generic "Login failed" to prevent state enumeration. + * The gs_verified cookie is cleared only on SUCCESS. On failure (e.g. wrong + * password), the cookie is preserved so the user can retry. Stale cookies + * are harmless — they auto-expire (5min maxAge) and check-session cleans them. */ @Public() @Post("login") @@ -468,7 +440,7 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl const sessionToken = req.cookies?.["gs_verified"] as string | undefined; if (!sessionToken) { - throw new UnauthorizedException("No verified session found"); + throw new UnauthorizedException("Login failed"); } const result = await this.loginWorkflow.execute(sessionToken, body.password); @@ -476,10 +448,9 @@ Add the two new endpoints (after the `migrateWhmcsAccount` method, before the cl // Set auth cookies setAuthCookies(res, result.tokens); - // Clear the handoff cookie - res.clearCookie("gs_verified", { - path: "/api/auth/get-started", - }); + // Clear the handoff cookie only on success + // (on failure the cookie stays so the user can retry with correct password) + res.clearCookie("gs_verified", { path: "/api/auth/get-started" }); return { user: result.user, @@ -541,6 +512,9 @@ import { type GetStartedLoginRequest, type CheckSessionResponse, } from "@customer-portal/domain/get-started"; + +// Also import the auth response schema for the login function +import { authResponseSchema } from "@customer-portal/domain/auth"; // adjust path to match existing pattern ``` Add two new functions at the bottom of the file (before the closing): @@ -602,6 +576,10 @@ Replace the entire component. Both inline and full-page modes now redirect to th * After OTP verification detects an existing account, the BFF sets * a gs_verified HttpOnly cookie. This component redirects to the * login page, which detects the cookie and shows password-only mode. + * + * Security: email is NOT included in the URL to avoid leaking into + * browser history, server logs, and referrer headers. The login page + * retrieves the (masked) email from the check-session endpoint instead. */ "use client"; @@ -613,18 +591,17 @@ import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine"; export function AccountStatusStep() { const { state } = useGetStartedMachine(); - const { formData, redirectTo, serviceContext } = state.context; + const { redirectTo, serviceContext } = state.context; const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account"); useEffect(() => { const loginUrl = - `/auth/login?email=${encodeURIComponent(formData.email)}` + - `&from=get-started` + + `/auth/login?from=get-started` + `&redirect=${encodeURIComponent(effectiveRedirectTo)}`; window.location.href = loginUrl; - }, [formData.email, effectiveRedirectTo]); + }, [effectiveRedirectTo]); // Show loading state while redirect happens return ( @@ -670,7 +647,7 @@ This is the most involved frontend change. The LoginForm needs to: 1. Detect `from=get-started` URL param on mount 2. Call `GET /auth/get-started/check-session` to validate the cookie -3. If valid: show password-only mode (pre-filled email, no OTP step) +3. If valid: show password-only mode (display masked email from response, no OTP step) 4. On submit: call `POST /auth/get-started/login` instead of normal login 5. If invalid: fall back to normal login flow @@ -691,10 +668,10 @@ This depends on the exact LoginForm structure. The implementation should: 1. Add `useEffect` at the top of the component that checks for `from=get-started` URL param 2. If found, call `checkGetStartedSession()` from the get-started API -3. If valid, set `handoffMode = true` and pre-fill email from response +3. If valid, set `handoffMode = true` and store the masked email for display 4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode -5. Hide the OTP step when in handoff mode -6. Show an info banner: "Email verified — enter your password to sign in" +5. Hide the email input and OTP step when in handoff mode +6. Show an info banner with masked email: "Email verified for ba\*\*\*@example.com — enter your password to sign in" Import the handoff API functions: @@ -712,6 +689,12 @@ const [handoffMode, setHandoffMode] = useState(false); const [handoffChecking, setHandoffChecking] = useState(false); ``` +Add state for the masked email display: + +```typescript +const [handoffMaskedEmail, setHandoffMaskedEmail] = useState(null); +``` + Add useEffect for handoff detection (near mount): ```typescript @@ -722,10 +705,10 @@ useEffect(() => { setHandoffChecking(true); checkGetStartedSession() .then(result => { - if (result.valid && result.email) { + if (result.valid && result.maskedEmail) { setHandoffMode(true); - // Pre-fill email from session - form.setValue("email", result.email); + // Store masked email for display (not the full email — that stays in the cookie) + setHandoffMaskedEmail(result.maskedEmail); } }) .catch(() => { @@ -750,24 +733,22 @@ if (handoffMode) { // ... existing login flow ``` -When `handoffMode` is true, render an info banner above the password field: +When `handoffMode` is true, render an info banner above the password field showing the masked email: ```typescript -{handoffMode && ( +{handoffMode && handoffMaskedEmail && (
- Email verified — enter your password to sign in. + Email verified for {handoffMaskedEmail} — enter your password to sign in.
)} ``` -Make the email field read-only in handoff mode: +Hide the email input entirely in handoff mode (the full email is never exposed to the frontend — only the masked version is shown in the banner above): ```typescript - +{!handoffMode && ( + +)} ``` Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP). diff --git a/packages/domain/auth/forms.ts b/packages/domain/auth/forms.ts index eeac6e8f..44a02a84 100644 --- a/packages/domain/auth/forms.ts +++ b/packages/domain/auth/forms.ts @@ -18,7 +18,6 @@ export const PASSWORD_REQUIREMENTS = [ { key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ }, { key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ }, { key: "number", label: "One number", regex: /[0-9]/ }, - { key: "special", label: "One special character", regex: /[^A-Za-z0-9]/ }, ] as const; export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"]; diff --git a/packages/domain/common/schema.ts b/packages/domain/common/schema.ts index 4c0d18c8..1dacb7c3 100644 --- a/packages/domain/common/schema.ts +++ b/packages/domain/common/schema.ts @@ -18,8 +18,7 @@ export const passwordSchema = z .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"); + .regex(/[0-9]/, "Password must contain at least one number"); export const nameSchema = z .string() diff --git a/packages/domain/customer/providers/whmcs/mapper.ts b/packages/domain/customer/providers/whmcs/mapper.ts index 2fef2657..9a01d49e 100644 --- a/packages/domain/customer/providers/whmcs/mapper.ts +++ b/packages/domain/customer/providers/whmcs/mapper.ts @@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined { country: client.country ?? null, countryCode: client.countrycode ?? null, phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null, - phoneCountryCode: client.phonecc ?? null, + phoneCountryCode: client.phonecc == null ? null : String(client.phonecc), }); const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");