|
|
|
@ -8,6 +8,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
**Tech Stack:** NestJS (BFF), Zod (validation), Redis (sessions), Argon2 (password), XState (frontend state), React (portal)
|
|
|
|
**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
|
|
|
|
### Task 1: Add domain schemas and types for login handoff
|
|
|
|
@ -45,8 +55,8 @@ export const getStartedLoginRequestSchema = z.object({
|
|
|
|
export const checkSessionResponseSchema = z.object({
|
|
|
|
export const checkSessionResponseSchema = z.object({
|
|
|
|
/** Whether a valid verified session exists */
|
|
|
|
/** Whether a valid verified session exists */
|
|
|
|
valid: z.boolean(),
|
|
|
|
valid: z.boolean(),
|
|
|
|
/** Email from the session (only present when valid) */
|
|
|
|
/** Masked email from the session, e.g. "ba***@example.com" (only present when valid) */
|
|
|
|
email: z.string().optional(),
|
|
|
|
maskedEmail: z.string().optional(),
|
|
|
|
});
|
|
|
|
});
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
@ -67,6 +77,14 @@ Add types after `MigrateWhmcsAccountRequest` (after line 94):
|
|
|
|
```typescript
|
|
|
|
```typescript
|
|
|
|
export type GetStartedLoginRequest = z.infer<typeof getStartedLoginRequestSchema>;
|
|
|
|
export type GetStartedLoginRequest = z.infer<typeof getStartedLoginRequestSchema>;
|
|
|
|
export type CheckSessionResponse = z.infer<typeof checkSessionResponseSchema>;
|
|
|
|
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`**
|
|
|
|
**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
|
|
|
|
```typescript
|
|
|
|
type GetStartedLoginRequest,
|
|
|
|
type GetStartedLoginRequest,
|
|
|
|
type CheckSessionResponse,
|
|
|
|
type CheckSessionResponse,
|
|
|
|
|
|
|
|
maskEmail,
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Add to the schemas export section (after `getStartedSessionSchema` on line 64):
|
|
|
|
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**
|
|
|
|
**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.
|
|
|
|
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
|
|
|
|
```typescript
|
|
|
|
/**
|
|
|
|
usedFor?: "signup_with_eligibility" | "complete_account" | "migrate_whmcs_account" | "login";
|
|
|
|
* 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 };
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Step 2: Verify types compile**
|
|
|
|
**Step 2: Verify types compile**
|
|
|
|
@ -275,7 +212,7 @@ import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
|
|
|
```typescript
|
|
|
|
```typescript
|
|
|
|
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
|
|
|
|
import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
|
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
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 { GetStartedSessionService } from "../otp/get-started-session.service.js";
|
|
|
|
import { AuthLoginService } from "../../application/auth-login.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> {
|
|
|
|
async execute(sessionToken: string, password: string): Promise<AuthResultInternal> {
|
|
|
|
// 1. Acquire lock and mark session as used (prevents replay)
|
|
|
|
// 1. Validate session exists and is verified (without consuming it)
|
|
|
|
const lockResult = await this.sessionService.acquireAndMarkAsUsed(sessionToken, "login");
|
|
|
|
const session = await this.sessionService.validateVerifiedSession(sessionToken);
|
|
|
|
|
|
|
|
|
|
|
|
if (!lockResult.success) {
|
|
|
|
if (!session) {
|
|
|
|
this.logger.warn(
|
|
|
|
this.logger.warn(
|
|
|
|
{ sessionToken, reason: lockResult.reason },
|
|
|
|
{ sessionTokenSuffix: sessionToken.slice(-4) },
|
|
|
|
"Login handoff session invalid"
|
|
|
|
"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
|
|
|
|
// 2. Verify the session was for a portal_exists account
|
|
|
|
if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
|
|
|
|
if (session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
|
|
|
|
this.logger.warn(
|
|
|
|
this.logger.warn(
|
|
|
|
{ sessionToken, accountStatus: session.accountStatus },
|
|
|
|
{ sessionTokenSuffix: sessionToken.slice(-4), accountStatus: session.accountStatus },
|
|
|
|
"Login handoff session has wrong account status"
|
|
|
|
"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);
|
|
|
|
const validatedUser = await this.loginService.validateUser(session.email, password);
|
|
|
|
|
|
|
|
|
|
|
|
if (!validatedUser) {
|
|
|
|
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, {
|
|
|
|
await this.usersService.update(validatedUser.id, {
|
|
|
|
lastLoginAt: new Date(),
|
|
|
|
lastLoginAt: new Date(),
|
|
|
|
failedLoginAttempts: 0,
|
|
|
|
failedLoginAttempts: 0,
|
|
|
|
lockedUntil: null,
|
|
|
|
lockedUntil: null,
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 5. Generate auth tokens
|
|
|
|
// 6. Generate auth tokens
|
|
|
|
const result = await this.generateAuthResult.execute(validatedUser.id);
|
|
|
|
const result = await this.generateAuthResult.execute(validatedUser.id);
|
|
|
|
|
|
|
|
|
|
|
|
// 6. Audit log
|
|
|
|
// 7. Audit log
|
|
|
|
await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, {
|
|
|
|
await this.auditService.logAuthEvent(AuditAction.LOGIN_SUCCESS, validatedUser.id, {
|
|
|
|
email: session.email,
|
|
|
|
email: session.email,
|
|
|
|
method: "get_started_handoff",
|
|
|
|
method: "get_started_handoff",
|
|
|
|
@ -345,16 +297,21 @@ export class GetStartedLoginWorkflowService {
|
|
|
|
return result;
|
|
|
|
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(
|
|
|
|
async checkSession(
|
|
|
|
sessionToken: string
|
|
|
|
sessionToken: string
|
|
|
|
): Promise<{ valid: true; email: string } | { valid: false }> {
|
|
|
|
): Promise<{ valid: true; maskedEmail: string } | { valid: false }> {
|
|
|
|
const session = await this.sessionService.validateVerifiedSession(sessionToken);
|
|
|
|
const session = await this.sessionService.validateVerifiedSession(sessionToken);
|
|
|
|
|
|
|
|
|
|
|
|
if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
|
|
|
|
if (!session || session.accountStatus !== ACCOUNT_STATUS.PORTAL_EXISTS) {
|
|
|
|
return { valid: false };
|
|
|
|
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
|
|
|
|
```typescript
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* Check if a valid get-started handoff session exists
|
|
|
|
* Check if a valid get-started handoff session exists.
|
|
|
|
* Used by the login page to detect OTP-bypass mode
|
|
|
|
* 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()
|
|
|
|
@Public()
|
|
|
|
@Get("check-session")
|
|
|
|
@Get("check-session")
|
|
|
|
async checkSession(
|
|
|
|
async checkSession(
|
|
|
|
@Req() req: Request
|
|
|
|
@Req() req: Request,
|
|
|
|
|
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
): Promise<CheckSessionResponseDto> {
|
|
|
|
): Promise<CheckSessionResponseDto> {
|
|
|
|
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
|
|
|
|
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 { 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.
|
|
|
|
* For portal_exists users after OTP verification in get-started flow.
|
|
|
|
* Session token comes from the gs_verified HttpOnly cookie.
|
|
|
|
* Session token comes from the gs_verified HttpOnly cookie.
|
|
|
|
* Skips login OTP since email was already verified via get-started OTP.
|
|
|
|
* 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()
|
|
|
|
@Public()
|
|
|
|
@Post("login")
|
|
|
|
@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;
|
|
|
|
const sessionToken = req.cookies?.["gs_verified"] as string | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (!sessionToken) {
|
|
|
|
if (!sessionToken) {
|
|
|
|
throw new UnauthorizedException("No verified session found");
|
|
|
|
throw new UnauthorizedException("Login failed");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await this.loginWorkflow.execute(sessionToken, body.password);
|
|
|
|
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
|
|
|
|
// Set auth cookies
|
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
|
|
|
|
|
|
|
|
// Clear the handoff cookie
|
|
|
|
// Clear the handoff cookie only on success
|
|
|
|
res.clearCookie("gs_verified", {
|
|
|
|
// (on failure the cookie stays so the user can retry with correct password)
|
|
|
|
path: "/api/auth/get-started",
|
|
|
|
res.clearCookie("gs_verified", { path: "/api/auth/get-started" });
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
user: result.user,
|
|
|
|
user: result.user,
|
|
|
|
@ -541,6 +512,9 @@ import {
|
|
|
|
type GetStartedLoginRequest,
|
|
|
|
type GetStartedLoginRequest,
|
|
|
|
type CheckSessionResponse,
|
|
|
|
type CheckSessionResponse,
|
|
|
|
} from "@customer-portal/domain/get-started";
|
|
|
|
} 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):
|
|
|
|
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
|
|
|
|
* After OTP verification detects an existing account, the BFF sets
|
|
|
|
* a gs_verified HttpOnly cookie. This component redirects to the
|
|
|
|
* a gs_verified HttpOnly cookie. This component redirects to the
|
|
|
|
* login page, which detects the cookie and shows password-only mode.
|
|
|
|
* 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";
|
|
|
|
"use client";
|
|
|
|
@ -613,18 +591,17 @@ import { useGetStartedMachine } from "../../../hooks/useGetStartedMachine";
|
|
|
|
|
|
|
|
|
|
|
|
export function AccountStatusStep() {
|
|
|
|
export function AccountStatusStep() {
|
|
|
|
const { state } = useGetStartedMachine();
|
|
|
|
const { state } = useGetStartedMachine();
|
|
|
|
const { formData, redirectTo, serviceContext } = state.context;
|
|
|
|
const { redirectTo, serviceContext } = state.context;
|
|
|
|
|
|
|
|
|
|
|
|
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
|
|
|
const effectiveRedirectTo = getSafeRedirect(redirectTo || serviceContext?.redirectTo, "/account");
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
const loginUrl =
|
|
|
|
const loginUrl =
|
|
|
|
`/auth/login?email=${encodeURIComponent(formData.email)}` +
|
|
|
|
`/auth/login?from=get-started` +
|
|
|
|
`&from=get-started` +
|
|
|
|
|
|
|
|
`&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
|
|
|
`&redirect=${encodeURIComponent(effectiveRedirectTo)}`;
|
|
|
|
|
|
|
|
|
|
|
|
window.location.href = loginUrl;
|
|
|
|
window.location.href = loginUrl;
|
|
|
|
}, [formData.email, effectiveRedirectTo]);
|
|
|
|
}, [effectiveRedirectTo]);
|
|
|
|
|
|
|
|
|
|
|
|
// Show loading state while redirect happens
|
|
|
|
// Show loading state while redirect happens
|
|
|
|
return (
|
|
|
|
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
|
|
|
|
1. Detect `from=get-started` URL param on mount
|
|
|
|
2. Call `GET /auth/get-started/check-session` to validate the cookie
|
|
|
|
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
|
|
|
|
4. On submit: call `POST /auth/get-started/login` instead of normal login
|
|
|
|
5. If invalid: fall back to normal login flow
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
4. Replace the `handleLogin` to call `loginWithGetStartedSession({ password })` when in handoff mode
|
|
|
|
5. Hide the OTP step when in handoff mode
|
|
|
|
5. Hide the email input and OTP step when in handoff mode
|
|
|
|
6. Show an info banner: "Email verified — enter your password to sign in"
|
|
|
|
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:
|
|
|
|
Import the handoff API functions:
|
|
|
|
|
|
|
|
|
|
|
|
@ -712,6 +689,12 @@ const [handoffMode, setHandoffMode] = useState(false);
|
|
|
|
const [handoffChecking, setHandoffChecking] = 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):
|
|
|
|
Add useEffect for handoff detection (near mount):
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
```typescript
|
|
|
|
@ -722,10 +705,10 @@ useEffect(() => {
|
|
|
|
setHandoffChecking(true);
|
|
|
|
setHandoffChecking(true);
|
|
|
|
checkGetStartedSession()
|
|
|
|
checkGetStartedSession()
|
|
|
|
.then(result => {
|
|
|
|
.then(result => {
|
|
|
|
if (result.valid && result.email) {
|
|
|
|
if (result.valid && result.maskedEmail) {
|
|
|
|
setHandoffMode(true);
|
|
|
|
setHandoffMode(true);
|
|
|
|
// Pre-fill email from session
|
|
|
|
// Store masked email for display (not the full email — that stays in the cookie)
|
|
|
|
form.setValue("email", result.email);
|
|
|
|
setHandoffMaskedEmail(result.maskedEmail);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
.catch(() => {
|
|
|
|
.catch(() => {
|
|
|
|
@ -750,24 +733,22 @@ if (handoffMode) {
|
|
|
|
// ... existing login flow
|
|
|
|
// ... 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
|
|
|
|
```typescript
|
|
|
|
{handoffMode && (
|
|
|
|
{handoffMode && handoffMaskedEmail && (
|
|
|
|
<div className="rounded-lg bg-success/10 p-3 text-sm text-success">
|
|
|
|
<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>
|
|
|
|
</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
|
|
|
|
```typescript
|
|
|
|
<Input
|
|
|
|
{!handoffMode && (
|
|
|
|
{...field}
|
|
|
|
<Input {...field} />
|
|
|
|
readOnly={handoffMode}
|
|
|
|
)}
|
|
|
|
className={handoffMode ? "bg-muted" : ""}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP).
|
|
|
|
Hide the OTP section when in handoff mode (the existing `step === "otp"` check should never trigger since we bypass OTP).
|
|
|
|
|