refactor: update password validation requirements and messaging

- Removed special character requirement from password validation across various components and schemas.
- Updated user-facing messages to reflect the new password criteria.
- Adjusted related validation logic in the domain and portal to ensure consistency.
This commit is contained in:
barsa 2026-03-02 17:40:47 +09:00
parent c96270c223
commit 29b511e44c
8 changed files with 148 additions and 157 deletions

View File

@ -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 };
}

View File

@ -76,8 +76,7 @@ export function PasswordChangeCard({
</button>
</div>
<p className="text-xs text-gray-500 mt-3">
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.
</p>
</SubCard>
);

View File

@ -30,7 +30,7 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
if (showHint) {
return (
<p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, numbers, and a special character
At least 8 characters with uppercase, lowercase, and numbers
</p>
);
}
@ -41,7 +41,6 @@ export function PasswordRequirements({ checks, showHint = false }: PasswordRequi
<RequirementCheck met={checks.hasUppercase} label="Uppercase letter" />
<RequirementCheck met={checks.hasLowercase} label="Lowercase letter" />
<RequirementCheck met={checks.hasNumber} label="Number" />
<RequirementCheck met={checks.hasSpecialChar} label="Special character" />
</div>
);
}

View File

@ -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 };

View File

@ -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<typeof getStartedLoginRequestSchema>;
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>;
/** 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<AuthResultInternal> {
// 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<AuthResultInternal> {
// 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<CheckSessionResponseDto> {
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<string | null>(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 && (
<div className="rounded-lg bg-success/10 p-3 text-sm text-success">
Email verified — enter your password to sign in.
Email verified for <strong>{handoffMaskedEmail}</strong> — enter your password to sign in.
</div>
)}
```
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
<Input
{...field}
readOnly={handoffMode}
className={handoffMode ? "bg-muted" : ""}
/>
{!handoffMode && (
<Input {...field} />
)}
```
Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP).

View File

@ -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"];

View File

@ -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()

View File

@ -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 !== "");