refactor: enhance WHMCS HTTP client error logging and streamline auth module
- Simplified error logging in WhmcsHttpClientService by removing action parameters from log messages for clarity. - Introduced RequestWithCookies type to improve type safety in auth controllers and utilities. - Refactored ValidatedUser interface to remove export, aligning with internal usage. - Removed deprecated auth error constants and streamlined error handling in various auth services. - Consolidated token handling logic by utilizing a shared hashToken utility across token services. - Removed unused and redundant methods in token services, improving code maintainability.
This commit is contained in:
parent
f6c0812061
commit
239de6f20a
@ -59,8 +59,7 @@ export class WhmcsHttpClientService {
|
|||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
responseTime: Date.now() - startTime,
|
responseTime: Date.now() - startTime,
|
||||||
},
|
},
|
||||||
"WHMCS HTTP request failed [%s]",
|
"WHMCS HTTP request failed"
|
||||||
action
|
|
||||||
);
|
);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
@ -146,8 +145,7 @@ export class WhmcsHttpClientService {
|
|||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
snippet,
|
snippet,
|
||||||
},
|
},
|
||||||
"WHMCS non-OK response body snippet [%s]",
|
"WHMCS non-OK response body snippet"
|
||||||
action
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -255,8 +253,7 @@ export class WhmcsHttpClientService {
|
|||||||
parseError: extractErrorMessage(parseError),
|
parseError: extractErrorMessage(parseError),
|
||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
},
|
},
|
||||||
"Invalid JSON response from WHMCS API [%s]",
|
"Invalid JSON response from WHMCS API"
|
||||||
action
|
|
||||||
);
|
);
|
||||||
throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
|
throw new WhmcsOperationException("Invalid JSON response from WHMCS API");
|
||||||
}
|
}
|
||||||
@ -272,8 +269,7 @@ export class WhmcsHttpClientService {
|
|||||||
: { responseText: responseText.slice(0, 500) }),
|
: { responseText: responseText.slice(0, 500) }),
|
||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
},
|
},
|
||||||
"WHMCS API returned invalid response structure [%s]",
|
"WHMCS API returned invalid response structure"
|
||||||
action
|
|
||||||
);
|
);
|
||||||
throw new WhmcsOperationException("Invalid response structure from WHMCS API");
|
throw new WhmcsOperationException("Invalid response structure from WHMCS API");
|
||||||
}
|
}
|
||||||
@ -291,12 +287,12 @@ export class WhmcsHttpClientService {
|
|||||||
// classifies severity and the business layer logs the final outcome.
|
// classifies severity and the business layer logs the final outcome.
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
{
|
{
|
||||||
|
action,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
errorCode,
|
errorCode,
|
||||||
params: redactForLogs(params),
|
params: redactForLogs(params),
|
||||||
},
|
},
|
||||||
"WHMCS API returned error [%s]",
|
"WHMCS API returned error"
|
||||||
action
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Return error response for the orchestrator to handle with proper exception types
|
// Return error response for the orchestrator to handle with proper exception types
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { UsersService } from "@bff/modules/users/application/users.service.js";
|
|||||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
|
||||||
export interface ValidatedUser {
|
interface ValidatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import type { User as PrismaUser } from "@prisma/client";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
||||||
import { AuthTokenService } from "../infra/token/token.service.js";
|
import { AuthTokenService } from "../infra/token/token.service.js";
|
||||||
import { AuthLoginService } from "./auth-login.service.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Orchestrator
|
* Auth Orchestrator
|
||||||
*
|
*
|
||||||
@ -36,18 +34,9 @@ export class AuthOrchestrator {
|
|||||||
private readonly auditService: AuditService,
|
private readonly auditService: AuditService,
|
||||||
private readonly tokenBlacklistService: TokenBlacklistService,
|
private readonly tokenBlacklistService: TokenBlacklistService,
|
||||||
private readonly tokenService: AuthTokenService,
|
private readonly tokenService: AuthTokenService,
|
||||||
private readonly loginService: AuthLoginService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async validateUser(
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
request?: Request
|
|
||||||
): Promise<{ id: string; email: string; role: string } | null> {
|
|
||||||
return this.loginService.validateUser(email, password, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete login after credential validation (and OTP verification if required)
|
* Complete login after credential validation (and OTP verification if required)
|
||||||
* Generates tokens and updates user state
|
* Generates tokens and updates user state
|
||||||
@ -171,29 +160,11 @@ export class AuthOrchestrator {
|
|||||||
* Create SSO link to WHMCS for general access
|
* Create SSO link to WHMCS for general access
|
||||||
*/
|
*/
|
||||||
async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
|
async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
|
||||||
try {
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
this.logger.log("Creating SSO link request");
|
const ssoDestination = "sso:custom_redirect";
|
||||||
|
const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination);
|
||||||
|
|
||||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
return this.whmcsSsoService.createSsoToken(whmcsClientId, ssoDestination, ssoRedirectPath);
|
||||||
const ssoDestination = "sso:custom_redirect";
|
|
||||||
const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination);
|
|
||||||
|
|
||||||
const result = await this.whmcsSsoService.createSsoToken(
|
|
||||||
whmcsClientId,
|
|
||||||
ssoDestination,
|
|
||||||
ssoRedirectPath
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log("SSO link created successfully");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error("SSO link creation failed", {
|
|
||||||
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
||||||
message: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshTokens(
|
async refreshTokens(
|
||||||
|
|||||||
@ -2,6 +2,14 @@ import type { User, UserAuth } from "@customer-portal/domain/customer";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import type { AuthTokens } from "@customer-portal/domain/auth";
|
import type { AuthTokens } from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express Request with typed cookies.
|
||||||
|
* Used across auth controllers and cookie utilities.
|
||||||
|
*/
|
||||||
|
export type RequestWithCookies = Omit<Request, "cookies"> & {
|
||||||
|
cookies?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type RequestWithUser = Request & { user: User };
|
export type RequestWithUser = Request & { user: User };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized Authentication Error Catalog
|
|
||||||
*
|
|
||||||
* Provides consistent error codes and messages across the auth module.
|
|
||||||
* Use these constants instead of hardcoding error messages.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const AUTH_ERRORS = {
|
|
||||||
// Token errors
|
|
||||||
TOKEN_INVALID: {
|
|
||||||
code: "TOKEN_INVALID",
|
|
||||||
message: "Invalid token",
|
|
||||||
},
|
|
||||||
TOKEN_EXPIRED: {
|
|
||||||
code: "TOKEN_EXPIRED",
|
|
||||||
message: "Token has expired",
|
|
||||||
},
|
|
||||||
TOKEN_REVOKED: {
|
|
||||||
code: "TOKEN_REVOKED",
|
|
||||||
message: "Token has been revoked",
|
|
||||||
},
|
|
||||||
TOKEN_MISSING: {
|
|
||||||
code: "TOKEN_MISSING",
|
|
||||||
message: "Missing token",
|
|
||||||
},
|
|
||||||
TOKEN_INVALID_TYPE: {
|
|
||||||
code: "TOKEN_INVALID_TYPE",
|
|
||||||
message: "Invalid access token",
|
|
||||||
},
|
|
||||||
TOKEN_INVALID_PAYLOAD: {
|
|
||||||
code: "TOKEN_INVALID_PAYLOAD",
|
|
||||||
message: "Invalid token payload",
|
|
||||||
},
|
|
||||||
TOKEN_MISSING_EXPIRATION: {
|
|
||||||
code: "TOKEN_MISSING_EXPIRATION",
|
|
||||||
message: "Token missing expiration claim",
|
|
||||||
},
|
|
||||||
TOKEN_EXPIRING_SOON: {
|
|
||||||
code: "TOKEN_EXPIRING_SOON",
|
|
||||||
message: "Token expired or expiring soon",
|
|
||||||
},
|
|
||||||
TOKEN_SUBJECT_MISMATCH: {
|
|
||||||
code: "TOKEN_SUBJECT_MISMATCH",
|
|
||||||
message: "Token subject does not match user record",
|
|
||||||
},
|
|
||||||
|
|
||||||
// User errors
|
|
||||||
USER_NOT_FOUND: {
|
|
||||||
code: "USER_NOT_FOUND",
|
|
||||||
message: "User not found",
|
|
||||||
},
|
|
||||||
ACCOUNT_EXISTS: {
|
|
||||||
code: "ACCOUNT_EXISTS",
|
|
||||||
message: "Account already exists",
|
|
||||||
},
|
|
||||||
INVALID_CREDENTIALS: {
|
|
||||||
code: "INVALID_CREDENTIALS",
|
|
||||||
message: "Invalid credentials",
|
|
||||||
},
|
|
||||||
EMAIL_NOT_VERIFIED: {
|
|
||||||
code: "EMAIL_NOT_VERIFIED",
|
|
||||||
message: "Email not verified",
|
|
||||||
},
|
|
||||||
ACCOUNT_DISABLED: {
|
|
||||||
code: "ACCOUNT_DISABLED",
|
|
||||||
message: "Account has been disabled",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Password errors
|
|
||||||
PASSWORD_TOO_WEAK: {
|
|
||||||
code: "PASSWORD_TOO_WEAK",
|
|
||||||
message: "Password does not meet requirements",
|
|
||||||
},
|
|
||||||
PASSWORD_MISMATCH: {
|
|
||||||
code: "PASSWORD_MISMATCH",
|
|
||||||
message: "Current password is incorrect",
|
|
||||||
},
|
|
||||||
PASSWORD_RESET_EXPIRED: {
|
|
||||||
code: "PASSWORD_RESET_EXPIRED",
|
|
||||||
message: "Password reset token has expired",
|
|
||||||
},
|
|
||||||
PASSWORD_RESET_INVALID: {
|
|
||||||
code: "PASSWORD_RESET_INVALID",
|
|
||||||
message: "Invalid password reset token",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Session errors
|
|
||||||
SESSION_EXPIRED: {
|
|
||||||
code: "SESSION_EXPIRED",
|
|
||||||
message: "Session has expired",
|
|
||||||
},
|
|
||||||
SESSION_INVALID: {
|
|
||||||
code: "SESSION_INVALID",
|
|
||||||
message: "Invalid session",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Request context errors
|
|
||||||
INVALID_REQUEST_CONTEXT: {
|
|
||||||
code: "INVALID_REQUEST_CONTEXT",
|
|
||||||
message: "Invalid request context",
|
|
||||||
},
|
|
||||||
|
|
||||||
// Service errors
|
|
||||||
SERVICE_UNAVAILABLE: {
|
|
||||||
code: "SERVICE_UNAVAILABLE",
|
|
||||||
message: "Authentication temporarily unavailable",
|
|
||||||
},
|
|
||||||
RATE_LIMITED: {
|
|
||||||
code: "RATE_LIMITED",
|
|
||||||
message: "Too many requests. Please try again later.",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type AuthErrorCode = keyof typeof AUTH_ERRORS;
|
|
||||||
export type AuthError = (typeof AUTH_ERRORS)[AuthErrorCode];
|
|
||||||
@ -91,7 +91,7 @@ export class LoginSessionService {
|
|||||||
* @param sessionToken - The session token (UUID)
|
* @param sessionToken - The session token (UUID)
|
||||||
* @returns Session data if found and valid, null otherwise
|
* @returns Session data if found and valid, null otherwise
|
||||||
*/
|
*/
|
||||||
async get(sessionToken: string): Promise<LoginSessionData | null> {
|
private async get(sessionToken: string): Promise<LoginSessionData | null> {
|
||||||
const sessionData = await this.cache.get<LoginSessionData>(this.buildKey(sessionToken));
|
const sessionData = await this.cache.get<LoginSessionData>(this.buildKey(sessionToken));
|
||||||
|
|
||||||
if (!sessionData) {
|
if (!sessionData) {
|
||||||
|
|||||||
@ -173,25 +173,6 @@ export class GetStartedSessionService {
|
|||||||
this.logger.debug({ sessionId: sessionToken }, "Get-started session invalidated");
|
this.logger.debug({ sessionId: sessionToken }, "Get-started session invalidated");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a session exists and email is verified
|
|
||||||
*/
|
|
||||||
async validateVerifiedSession(sessionToken: string): Promise<GetStartedSession | null> {
|
|
||||||
const session = await this.get(sessionToken);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
this.logger.warn({ sessionId: sessionToken }, "Session not found");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.emailVerified) {
|
|
||||||
this.logger.warn({ sessionId: sessionToken }, "Session email not verified");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Guest Handoff Token Methods
|
// Guest Handoff Token Methods
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -136,16 +136,6 @@ export class OtpService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an OTP exists for the given email (without consuming attempts)
|
|
||||||
*/
|
|
||||||
async exists(email: string): Promise<boolean> {
|
|
||||||
const normalizedEmail = this.normalizeEmail(email);
|
|
||||||
const key = this.buildKey(normalizedEmail);
|
|
||||||
const otpData = await this.cache.get<OtpData>(key);
|
|
||||||
return otpData !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete any existing OTP for the given email
|
* Delete any existing OTP for the given email
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
|
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
|
||||||
import { createHash } from "crypto";
|
|
||||||
import type { Redis } from "ioredis";
|
import type { Redis } from "ioredis";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import {
|
import {
|
||||||
@ -29,10 +28,6 @@ export interface RateLimitOutcome {
|
|||||||
export class AuthRateLimitService {
|
export class AuthRateLimitService {
|
||||||
private readonly loginLimiter: RateLimiterRedis;
|
private readonly loginLimiter: RateLimiterRedis;
|
||||||
|
|
||||||
private readonly refreshLimiter: RateLimiterRedis;
|
|
||||||
|
|
||||||
private readonly signupLimiter: RateLimiterRedis;
|
|
||||||
|
|
||||||
private readonly passwordResetLimiter: RateLimiterRedis;
|
private readonly passwordResetLimiter: RateLimiterRedis;
|
||||||
|
|
||||||
private readonly loginCaptchaThreshold: number;
|
private readonly loginCaptchaThreshold: number;
|
||||||
@ -47,29 +42,21 @@ export class AuthRateLimitService {
|
|||||||
const loginLimit = this.configService.get<number>("LOGIN_RATE_LIMIT_LIMIT", 20);
|
const loginLimit = this.configService.get<number>("LOGIN_RATE_LIMIT_LIMIT", 20);
|
||||||
const loginTtlMs = this.configService.get<number>("LOGIN_RATE_LIMIT_TTL", 300000);
|
const loginTtlMs = this.configService.get<number>("LOGIN_RATE_LIMIT_TTL", 300000);
|
||||||
|
|
||||||
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
|
|
||||||
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
|
|
||||||
|
|
||||||
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
|
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
|
||||||
const passwordResetTtlMs = this.configService.get<number>(
|
const passwordResetTtlMs = this.configService.get<number>(
|
||||||
"PASSWORD_RESET_RATE_LIMIT_TTL",
|
"PASSWORD_RESET_RATE_LIMIT_TTL",
|
||||||
900000
|
900000
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
|
|
||||||
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
|
|
||||||
|
|
||||||
this.loginCaptchaThreshold = this.configService.get<number>("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 5);
|
this.loginCaptchaThreshold = this.configService.get<number>("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 5);
|
||||||
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
|
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
|
||||||
|
|
||||||
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
|
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
|
||||||
this.signupLimiter = this.createLimiter("auth-signup", signupLimit, signupTtlMs);
|
|
||||||
this.passwordResetLimiter = this.createLimiter(
|
this.passwordResetLimiter = this.createLimiter(
|
||||||
"auth-password-reset",
|
"auth-password-reset",
|
||||||
passwordResetLimit,
|
passwordResetLimit,
|
||||||
passwordResetTtlMs
|
passwordResetTtlMs
|
||||||
);
|
);
|
||||||
this.refreshLimiter = this.createLimiter("auth-refresh", refreshLimit, refreshTtlMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async consumeLoginAttempt(request: Request): Promise<RateLimitOutcome> {
|
async consumeLoginAttempt(request: Request): Promise<RateLimitOutcome> {
|
||||||
@ -86,31 +73,12 @@ export class AuthRateLimitService {
|
|||||||
await this.deleteKey(this.loginLimiter, key, "login");
|
await this.deleteKey(this.loginLimiter, key, "login");
|
||||||
}
|
}
|
||||||
|
|
||||||
async consumeSignupAttempt(request: Request): Promise<RateLimitOutcome> {
|
|
||||||
const key = this.buildKey("signup", request);
|
|
||||||
const outcome = await this.consume(this.signupLimiter, key, "signup");
|
|
||||||
return { ...outcome, needsCaptcha: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async consumePasswordReset(request: Request): Promise<RateLimitOutcome> {
|
async consumePasswordReset(request: Request): Promise<RateLimitOutcome> {
|
||||||
const key = this.buildKey("password-reset", request);
|
const key = this.buildKey("password-reset", request);
|
||||||
const outcome = await this.consume(this.passwordResetLimiter, key, "password-reset");
|
const outcome = await this.consume(this.passwordResetLimiter, key, "password-reset");
|
||||||
return { ...outcome, needsCaptcha: false };
|
return { ...outcome, needsCaptcha: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
async consumeRefreshAttempt(request: Request, refreshToken: string): Promise<RateLimitOutcome> {
|
|
||||||
const tokenHash = this.hashToken(refreshToken);
|
|
||||||
const key = this.buildKey("refresh", request, tokenHash);
|
|
||||||
return this.consume(this.refreshLimiter, key, "refresh");
|
|
||||||
}
|
|
||||||
|
|
||||||
getCaptchaHeaderValue(needsCaptcha: boolean): string {
|
|
||||||
if (needsCaptcha || this.captchaAlwaysOn) {
|
|
||||||
return "required";
|
|
||||||
}
|
|
||||||
return "optional";
|
|
||||||
}
|
|
||||||
|
|
||||||
private requiresCaptcha(consumedPoints: number): boolean {
|
private requiresCaptcha(consumedPoints: number): boolean {
|
||||||
if (this.loginCaptchaThreshold <= 0) {
|
if (this.loginCaptchaThreshold <= 0) {
|
||||||
return false;
|
return false;
|
||||||
@ -208,8 +176,4 @@ export class AuthRateLimitService {
|
|||||||
const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
const timeMessage = remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||||
return `Too many ${context} attempts. Try again in ${timeMessage}.`;
|
return `Too many ${context} attempts. Try again in ${timeMessage}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
|
||||||
return createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
export type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
||||||
|
export { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
|
||||||
export { AuthTokenService } from "./token.service.js";
|
export { AuthTokenService } from "./token.service.js";
|
||||||
export { TokenGeneratorService } from "./token-generator.service.js";
|
export { TokenGeneratorService } from "./token-generator.service.js";
|
||||||
export { TokenRefreshService } from "./token-refresh.service.js";
|
export { TokenRefreshService } from "./token-refresh.service.js";
|
||||||
|
|||||||
@ -145,31 +145,6 @@ export class PasswordResetTokenService {
|
|||||||
return { userId: payload.sub };
|
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<void> {
|
|
||||||
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<void> {
|
|
||||||
// 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
|
* Build Redis key for token storage
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { createHash } from "crypto";
|
|
||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { hashToken } from "../../utils/hash-token.util.js";
|
||||||
import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
|
import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
|
||||||
import { JoseJwtService } from "./jose-jwt.service.js";
|
import { JoseJwtService } from "./jose-jwt.service.js";
|
||||||
|
|
||||||
@ -108,10 +108,6 @@ export class TokenBlacklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildBlacklistKey(token: string): string {
|
private buildBlacklistKey(token: string): string {
|
||||||
return `blacklist:${this.hashToken(token)}`;
|
return `blacklist:${hashToken(token)}`;
|
||||||
}
|
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
|
||||||
return createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Redis } from "ioredis";
|
import { Redis } from "ioredis";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { randomBytes, createHash } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import type { AuthTokens } from "@customer-portal/domain/auth";
|
import type { AuthTokens } from "@customer-portal/domain/auth";
|
||||||
import type { UserRole } from "@customer-portal/domain/customer";
|
import type { UserRole } from "@customer-portal/domain/customer";
|
||||||
|
import { parseJwtExpiry, parseJwtExpiryMs } from "../../utils/jwt-expiry.util.js";
|
||||||
|
import { hashToken } from "../../utils/hash-token.util.js";
|
||||||
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
||||||
|
import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
|
||||||
import { JoseJwtService } from "./jose-jwt.service.js";
|
import { JoseJwtService } from "./jose-jwt.service.js";
|
||||||
import { TokenStorageService } from "./token-storage.service.js";
|
import { TokenStorageService } from "./token-storage.service.js";
|
||||||
|
|
||||||
@ -22,9 +25,6 @@ const ERROR_SERVICE_UNAVAILABLE = "Authentication service temporarily unavailabl
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenGeneratorService {
|
export class TokenGeneratorService {
|
||||||
readonly ACCESS_TOKEN_EXPIRY = "15m";
|
|
||||||
readonly REFRESH_TOKEN_EXPIRY = "7d";
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly jwtService: JoseJwtService,
|
private readonly jwtService: JoseJwtService,
|
||||||
private readonly storage: TokenStorageService,
|
private readonly storage: TokenStorageService,
|
||||||
@ -77,12 +77,12 @@ export class TokenGeneratorService {
|
|||||||
type: "refresh",
|
type: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
|
const accessToken = await this.jwtService.sign(accessPayload, ACCESS_TOKEN_EXPIRY);
|
||||||
|
|
||||||
const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
|
const refreshExpirySeconds = parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
|
||||||
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
|
const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);
|
||||||
|
|
||||||
const refreshTokenHash = this.hashToken(refreshToken);
|
const refreshTokenHash = hashToken(refreshToken);
|
||||||
const refreshAbsoluteExpiresAt = new Date(
|
const refreshAbsoluteExpiresAt = new Date(
|
||||||
Date.now() + refreshExpirySeconds * 1000
|
Date.now() + refreshExpirySeconds * 1000
|
||||||
).toISOString();
|
).toISOString();
|
||||||
@ -112,7 +112,7 @@ export class TokenGeneratorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessExpiresAt = new Date(
|
const accessExpiresAt = new Date(
|
||||||
Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
|
Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
|
||||||
).toISOString();
|
).toISOString();
|
||||||
const refreshExpiresAt = refreshAbsoluteExpiresAt;
|
const refreshExpiresAt = refreshAbsoluteExpiresAt;
|
||||||
|
|
||||||
@ -161,9 +161,9 @@ export class TokenGeneratorService {
|
|||||||
type: "refresh",
|
type: "refresh",
|
||||||
};
|
};
|
||||||
|
|
||||||
const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
|
const newAccessToken = await this.jwtService.sign(accessPayload, ACCESS_TOKEN_EXPIRY);
|
||||||
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
|
const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
|
||||||
const newRefreshTokenHash = this.hashToken(newRefreshToken);
|
const newRefreshTokenHash = hashToken(newRefreshToken);
|
||||||
|
|
||||||
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
|
return { newAccessToken, newRefreshToken, newRefreshTokenHash };
|
||||||
}
|
}
|
||||||
@ -171,30 +171,4 @@ export class TokenGeneratorService {
|
|||||||
generateTokenId(): string {
|
generateTokenId(): string {
|
||||||
return randomBytes(32).toString("hex");
|
return randomBytes(32).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
hashToken(token: string): string {
|
|
||||||
return createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
parseExpiryToMs(expiry: string): number {
|
|
||||||
const unit = expiry.slice(-1);
|
|
||||||
const value = Number.parseInt(expiry.slice(0, -1));
|
|
||||||
|
|
||||||
switch (unit) {
|
|
||||||
case "s":
|
|
||||||
return value * 1000;
|
|
||||||
case "m":
|
|
||||||
return value * 60 * 1000;
|
|
||||||
case "h":
|
|
||||||
return value * 60 * 60 * 1000;
|
|
||||||
case "d":
|
|
||||||
return value * 24 * 60 * 60 * 1000;
|
|
||||||
default:
|
|
||||||
return 15 * 60 * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parseExpiryToSeconds(expiry: string): number {
|
|
||||||
return Math.floor(this.parseExpiryToMs(expiry) / 1000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,452 +0,0 @@
|
|||||||
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { Redis } from "ioredis";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
|
|
||||||
export interface MigrationStats {
|
|
||||||
totalKeysScanned: number;
|
|
||||||
familiesFound: number;
|
|
||||||
familiesMigrated: number;
|
|
||||||
tokensFound: number;
|
|
||||||
tokensMigrated: number;
|
|
||||||
orphanedTokens: number;
|
|
||||||
errors: number;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedFamily {
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedToken {
|
|
||||||
userId: string;
|
|
||||||
familyId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TokenMigrationService {
|
|
||||||
private readonly REFRESH_TOKEN_FAMILY_PREFIX = "refresh_family:";
|
|
||||||
private readonly REFRESH_TOKEN_PREFIX = "refresh_token:";
|
|
||||||
private readonly REFRESH_USER_SET_PREFIX = "refresh_user:";
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate existing refresh tokens to the new per-user token set structure
|
|
||||||
*/
|
|
||||||
async migrateExistingTokens(dryRun = true): Promise<MigrationStats> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const stats: MigrationStats = {
|
|
||||||
totalKeysScanned: 0,
|
|
||||||
familiesFound: 0,
|
|
||||||
familiesMigrated: 0,
|
|
||||||
tokensFound: 0,
|
|
||||||
tokensMigrated: 0,
|
|
||||||
orphanedTokens: 0,
|
|
||||||
errors: 0,
|
|
||||||
duration: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log("Starting token migration", { dryRun });
|
|
||||||
|
|
||||||
if (this.redis.status !== "ready") {
|
|
||||||
throw new ServiceUnavailableException("Redis is not ready for migration");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First, scan for all refresh token families
|
|
||||||
await this.migrateFamilies(stats, dryRun);
|
|
||||||
|
|
||||||
// Then, scan for orphaned tokens (tokens without families)
|
|
||||||
await this.migrateOrphanedTokens(stats, dryRun);
|
|
||||||
|
|
||||||
stats.duration = Date.now() - startTime;
|
|
||||||
|
|
||||||
this.logger.log("Token migration completed", {
|
|
||||||
dryRun,
|
|
||||||
stats,
|
|
||||||
});
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
} catch (error) {
|
|
||||||
stats.duration = Date.now() - startTime;
|
|
||||||
stats.errors++;
|
|
||||||
|
|
||||||
this.logger.error("Token migration failed", {
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
stats,
|
|
||||||
});
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate refresh token families to per-user sets
|
|
||||||
*/
|
|
||||||
private async migrateFamilies(stats: MigrationStats, dryRun: boolean): Promise<void> {
|
|
||||||
let cursor = "0";
|
|
||||||
const pattern = `${this.REFRESH_TOKEN_FAMILY_PREFIX}*`;
|
|
||||||
|
|
||||||
do {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
|
|
||||||
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
||||||
cursor = nextCursor;
|
|
||||||
stats.totalKeysScanned += keys.length;
|
|
||||||
|
|
||||||
if (keys && keys.length > 0) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
keys.map(async key => this.migrateSingleFamily(key, stats, dryRun))
|
|
||||||
);
|
|
||||||
this.countSettledErrors(results, stats, "Failed to migrate family");
|
|
||||||
}
|
|
||||||
} while (cursor !== "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate a single refresh token family
|
|
||||||
*/
|
|
||||||
private async migrateSingleFamily(
|
|
||||||
familyKey: string,
|
|
||||||
stats: MigrationStats,
|
|
||||||
dryRun: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const familyData = await this.redis.get(familyKey);
|
|
||||||
if (!familyData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.familiesFound++;
|
|
||||||
|
|
||||||
const family = this.parseFamilyData(familyKey, familyData, stats);
|
|
||||||
if (!family) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const familyId = familyKey.replace(this.REFRESH_TOKEN_FAMILY_PREFIX, "");
|
|
||||||
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${family.userId}`;
|
|
||||||
|
|
||||||
// Check if this family is already in the user's set
|
|
||||||
const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, familyId);
|
|
||||||
|
|
||||||
if (isAlreadyMigrated) {
|
|
||||||
this.logger.debug("Family already migrated", { familyKey, userId: family.userId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
// Add family to user's token set
|
|
||||||
const pipeline = this.redis.pipeline();
|
|
||||||
pipeline.sadd(userFamilySetKey, familyId);
|
|
||||||
|
|
||||||
// Set expiration on the user set (use the same TTL as the family)
|
|
||||||
const ttl = await this.redis.ttl(familyKey);
|
|
||||||
if (ttl > 0) {
|
|
||||||
pipeline.expire(userFamilySetKey, ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline.exec();
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.familiesMigrated++;
|
|
||||||
|
|
||||||
this.logger.debug("Migrated family to user set", {
|
|
||||||
familyKey,
|
|
||||||
userId: family.userId,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate orphaned tokens (tokens without corresponding families)
|
|
||||||
*/
|
|
||||||
private async migrateOrphanedTokens(stats: MigrationStats, dryRun: boolean): Promise<void> {
|
|
||||||
let cursor = "0";
|
|
||||||
const pattern = `${this.REFRESH_TOKEN_PREFIX}*`;
|
|
||||||
|
|
||||||
do {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
|
|
||||||
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
||||||
cursor = nextCursor;
|
|
||||||
stats.totalKeysScanned += keys.length;
|
|
||||||
|
|
||||||
if (keys && keys.length > 0) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
keys.map(async key => this.migrateSingleToken(key, stats, dryRun))
|
|
||||||
);
|
|
||||||
this.countSettledErrors(results, stats, "Failed to migrate token");
|
|
||||||
}
|
|
||||||
} while (cursor !== "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate a single refresh token
|
|
||||||
*/
|
|
||||||
private async migrateSingleToken(
|
|
||||||
tokenKey: string,
|
|
||||||
stats: MigrationStats,
|
|
||||||
dryRun: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const tokenData = await this.redis.get(tokenKey);
|
|
||||||
if (!tokenData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.tokensFound++;
|
|
||||||
|
|
||||||
const token = this.parseTokenData(tokenKey, tokenData, stats);
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the corresponding family exists
|
|
||||||
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
|
|
||||||
const familyExists = await this.redis.exists(familyKey);
|
|
||||||
|
|
||||||
if (!familyExists) {
|
|
||||||
stats.orphanedTokens++;
|
|
||||||
this.logger.warn("Found orphaned token (no corresponding family)", {
|
|
||||||
tokenKey,
|
|
||||||
familyId: token.familyId,
|
|
||||||
userId: token.userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
// Remove orphaned token
|
|
||||||
await this.redis.del(tokenKey);
|
|
||||||
this.logger.debug("Removed orphaned token", { tokenKey });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this token's family is already in the user's set
|
|
||||||
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${token.userId}`;
|
|
||||||
const isAlreadyMigrated = await this.redis.sismember(userFamilySetKey, token.familyId);
|
|
||||||
|
|
||||||
if (isAlreadyMigrated) {
|
|
||||||
stats.tokensMigrated++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
// Add family to user's token set
|
|
||||||
const pipeline = this.redis.pipeline();
|
|
||||||
pipeline.sadd(userFamilySetKey, token.familyId);
|
|
||||||
|
|
||||||
// Set expiration on the user set (use the same TTL as the token)
|
|
||||||
const ttl = await this.redis.ttl(tokenKey);
|
|
||||||
if (ttl > 0) {
|
|
||||||
pipeline.expire(userFamilySetKey, ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline.exec();
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.tokensMigrated++;
|
|
||||||
|
|
||||||
this.logger.debug("Migrated token family to user set", {
|
|
||||||
tokenKey,
|
|
||||||
familyId: token.familyId,
|
|
||||||
userId: token.userId,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up orphaned tokens and expired families
|
|
||||||
*/
|
|
||||||
async cleanupOrphanedTokens(dryRun = true): Promise<{ removed: number; errors: number }> {
|
|
||||||
const stats = { removed: 0, errors: 0 };
|
|
||||||
|
|
||||||
this.logger.log("Starting orphaned token cleanup", { dryRun });
|
|
||||||
|
|
||||||
let cursor = "0";
|
|
||||||
const pattern = `${this.REFRESH_TOKEN_PREFIX}*`;
|
|
||||||
|
|
||||||
do {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
|
|
||||||
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
||||||
cursor = nextCursor;
|
|
||||||
|
|
||||||
if (keys && keys.length > 0) {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Batch processing within SCAN loop
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
keys.map(async key => this.cleanupSingleToken(key, stats, dryRun))
|
|
||||||
);
|
|
||||||
this.countSettledErrors(results, stats, "Failed to cleanup token");
|
|
||||||
}
|
|
||||||
} while (cursor !== "0");
|
|
||||||
|
|
||||||
this.logger.log("Orphaned token cleanup completed", { dryRun, stats });
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up a single orphaned token
|
|
||||||
*/
|
|
||||||
private async cleanupSingleToken(
|
|
||||||
key: string,
|
|
||||||
stats: { removed: number; errors: number },
|
|
||||||
dryRun: boolean
|
|
||||||
): Promise<void> {
|
|
||||||
const tokenData = await this.redis.get(key);
|
|
||||||
if (!tokenData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = this.parseTokenData(key, tokenData, stats);
|
|
||||||
if (!token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${token.familyId}`;
|
|
||||||
const familyExists = await this.redis.exists(familyKey);
|
|
||||||
|
|
||||||
if (familyExists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dryRun) {
|
|
||||||
await this.redis.del(key);
|
|
||||||
}
|
|
||||||
stats.removed++;
|
|
||||||
|
|
||||||
this.logger.debug("Removed orphaned token", {
|
|
||||||
tokenKey: key,
|
|
||||||
familyId: token.familyId,
|
|
||||||
dryRun,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get migration status and statistics
|
|
||||||
*/
|
|
||||||
async getMigrationStatus(): Promise<{
|
|
||||||
totalFamilies: number;
|
|
||||||
totalTokens: number;
|
|
||||||
migratedUsers: number;
|
|
||||||
orphanedTokens: number;
|
|
||||||
needsMigration: boolean;
|
|
||||||
}> {
|
|
||||||
const [totalFamilies, totalTokens, migratedUsers] = await Promise.all([
|
|
||||||
this.countKeysByPattern(`${this.REFRESH_TOKEN_FAMILY_PREFIX}*`),
|
|
||||||
this.countKeysByPattern(`${this.REFRESH_TOKEN_PREFIX}*`),
|
|
||||||
this.countKeysByPattern(`${this.REFRESH_USER_SET_PREFIX}*`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalFamilies,
|
|
||||||
totalTokens,
|
|
||||||
migratedUsers,
|
|
||||||
orphanedTokens: 0,
|
|
||||||
needsMigration: totalFamilies > 0 && migratedUsers === 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count keys matching a pattern using Redis SCAN
|
|
||||||
*/
|
|
||||||
private async countKeysByPattern(pattern: string): Promise<number> {
|
|
||||||
let count = 0;
|
|
||||||
let cursor = "0";
|
|
||||||
|
|
||||||
do {
|
|
||||||
// eslint-disable-next-line no-await-in-loop -- Redis SCAN requires sequential cursor iteration
|
|
||||||
const [nextCursor, keys] = await this.redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
|
|
||||||
cursor = nextCursor;
|
|
||||||
count += keys.length;
|
|
||||||
} while (cursor !== "0");
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseFamilyData(
|
|
||||||
familyKey: string,
|
|
||||||
raw: string,
|
|
||||||
tracker: { errors: number }
|
|
||||||
): ParsedFamily | null {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
tracker.errors++;
|
|
||||||
this.logger.error("Failed to parse family data", {
|
|
||||||
familyKey,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
this.logger.warn("Invalid family structure, skipping", { familyKey });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
|
||||||
const userId = record["userId"];
|
|
||||||
|
|
||||||
if (typeof userId !== "string" || userId.length === 0) {
|
|
||||||
this.logger.warn("Invalid family structure, skipping", { familyKey });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseTokenData(
|
|
||||||
tokenKey: string,
|
|
||||||
raw: string,
|
|
||||||
tracker: { errors: number }
|
|
||||||
): ParsedToken | null {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch (error) {
|
|
||||||
tracker.errors++;
|
|
||||||
this.logger.error("Failed to parse token data", {
|
|
||||||
tokenKey,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
this.logger.warn("Invalid token structure, skipping", { tokenKey });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = parsed as Record<string, unknown>;
|
|
||||||
const userId = record["userId"];
|
|
||||||
const familyId = record["familyId"];
|
|
||||||
|
|
||||||
if (typeof userId !== "string" || typeof familyId !== "string") {
|
|
||||||
this.logger.warn("Invalid token structure, skipping", { tokenKey });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userId, familyId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count and log errors from Promise.allSettled results
|
|
||||||
*/
|
|
||||||
private countSettledErrors(
|
|
||||||
results: PromiseSettledResult<void>[],
|
|
||||||
stats: { errors: number },
|
|
||||||
message: string
|
|
||||||
): void {
|
|
||||||
for (const result of results) {
|
|
||||||
if (result.status === "rejected") {
|
|
||||||
stats.errors++;
|
|
||||||
this.logger.error(message, {
|
|
||||||
error: extractErrorMessage(result.reason),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,7 +11,10 @@ import type { AuthTokens } from "@customer-portal/domain/auth";
|
|||||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||||
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
import { UsersService } from "@bff/modules/users/application/users.service.js";
|
||||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||||
|
import { parseJwtExpiry, parseJwtExpiryMs } from "../../utils/jwt-expiry.util.js";
|
||||||
|
import { hashToken } from "../../utils/hash-token.util.js";
|
||||||
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
|
||||||
|
import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
|
||||||
import { TokenGeneratorService } from "./token-generator.service.js";
|
import { TokenGeneratorService } from "./token-generator.service.js";
|
||||||
import { TokenStorageService } from "./token-storage.service.js";
|
import { TokenStorageService } from "./token-storage.service.js";
|
||||||
import { TokenRevocationService } from "./token-revocation.service.js";
|
import { TokenRevocationService } from "./token-revocation.service.js";
|
||||||
@ -87,7 +90,7 @@ export class TokenRefreshService {
|
|||||||
): Promise<ValidatedTokenContext> {
|
): Promise<ValidatedTokenContext> {
|
||||||
const payload = await this.verifyRefreshTokenPayload(refreshToken);
|
const payload = await this.verifyRefreshTokenPayload(refreshToken);
|
||||||
const familyId = this.extractFamilyId(payload);
|
const familyId = this.extractFamilyId(payload);
|
||||||
const refreshTokenHash = this.generator.hashToken(refreshToken);
|
const refreshTokenHash = hashToken(refreshToken);
|
||||||
|
|
||||||
await this.validateStoredToken(refreshTokenHash, familyId, payload);
|
await this.validateStoredToken(refreshTokenHash, familyId, payload);
|
||||||
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
|
const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
|
||||||
@ -235,7 +238,7 @@ export class TokenRefreshService {
|
|||||||
const tokenTtl = await this.storage.getTtl(tokenKey);
|
const tokenTtl = await this.storage.getTtl(tokenKey);
|
||||||
return typeof tokenTtl === "number" && tokenTtl > 0
|
return typeof tokenTtl === "number" && tokenTtl > 0
|
||||||
? tokenTtl
|
? tokenTtl
|
||||||
: this.generator.parseExpiryToSeconds(this.generator.REFRESH_TOKEN_EXPIRY);
|
: parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async performTokenRotation(
|
private async performTokenRotation(
|
||||||
@ -283,7 +286,7 @@ export class TokenRefreshService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessExpiresAt = new Date(
|
const accessExpiresAt = new Date(
|
||||||
Date.now() + this.generator.parseExpiryToMs(this.generator.ACCESS_TOKEN_EXPIRY)
|
Date.now() + parseJwtExpiryMs(ACCESS_TOKEN_EXPIRY)
|
||||||
).toISOString();
|
).toISOString();
|
||||||
|
|
||||||
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
|
this.logger.debug("Refreshed token pair", { userId: context.payload.userId });
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { createHash } from "crypto";
|
import { hashToken } from "../../utils/hash-token.util.js";
|
||||||
import { TokenStorageService } from "./token-storage.service.js";
|
import { TokenStorageService } from "./token-storage.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -23,7 +23,7 @@ export class TokenRevocationService {
|
|||||||
*/
|
*/
|
||||||
async revokeRefreshToken(refreshToken: string): Promise<void> {
|
async revokeRefreshToken(refreshToken: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const tokenHash = this.hashToken(refreshToken);
|
const tokenHash = hashToken(refreshToken);
|
||||||
const tokenRecord = await this.storage.getTokenByHash(tokenHash);
|
const tokenRecord = await this.storage.getTokenByHash(tokenHash);
|
||||||
|
|
||||||
if (tokenRecord) {
|
if (tokenRecord) {
|
||||||
@ -212,8 +212,4 @@ export class TokenRevocationService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private hashToken(token: string): string {
|
|
||||||
return createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,16 +27,6 @@ export interface StoreRefreshTokenParams {
|
|||||||
absoluteExpiresAt?: string;
|
absoluteExpiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFamilyParams {
|
|
||||||
familyId: string;
|
|
||||||
userId: string;
|
|
||||||
newTokenHash: string;
|
|
||||||
deviceInfo?: DeviceInfo;
|
|
||||||
createdAt: string;
|
|
||||||
absoluteExpiresAt: string;
|
|
||||||
ttlSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AtomicTokenRotationParams {
|
export interface AtomicTokenRotationParams {
|
||||||
oldTokenHash: string;
|
oldTokenHash: string;
|
||||||
newTokenHash: string;
|
newTokenHash: string;
|
||||||
@ -223,19 +213,6 @@ export class TokenStorageService {
|
|||||||
return { token, family };
|
return { token, family };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a token as invalid (used during rotation)
|
|
||||||
*/
|
|
||||||
async markTokenInvalid(
|
|
||||||
tokenHash: string,
|
|
||||||
familyId: string,
|
|
||||||
userId: string,
|
|
||||||
ttlSeconds: number
|
|
||||||
): Promise<void> {
|
|
||||||
const key = `${this.REFRESH_TOKEN_PREFIX}${tokenHash}`;
|
|
||||||
await this.redis.setex(key, ttlSeconds, JSON.stringify({ familyId, userId, valid: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically validate and rotate a refresh token.
|
* Atomically validate and rotate a refresh token.
|
||||||
* Uses Redis Lua script to prevent race conditions during concurrent refresh requests.
|
* Uses Redis Lua script to prevent race conditions during concurrent refresh requests.
|
||||||
@ -338,35 +315,6 @@ export class TokenStorageService {
|
|||||||
return { success: false, error: result[1] };
|
return { success: false, error: result[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update token family with new token
|
|
||||||
*/
|
|
||||||
async updateFamily(params: UpdateFamilyParams): Promise<void> {
|
|
||||||
const { familyId, userId, newTokenHash, deviceInfo, createdAt, absoluteExpiresAt, ttlSeconds } =
|
|
||||||
params;
|
|
||||||
const familyKey = `${this.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
|
|
||||||
const tokenKey = `${this.REFRESH_TOKEN_PREFIX}${newTokenHash}`;
|
|
||||||
const userFamilySetKey = `${this.REFRESH_USER_SET_PREFIX}${userId}`;
|
|
||||||
|
|
||||||
const pipeline = this.redis.pipeline();
|
|
||||||
pipeline.setex(
|
|
||||||
familyKey,
|
|
||||||
ttlSeconds,
|
|
||||||
JSON.stringify({
|
|
||||||
userId,
|
|
||||||
tokenHash: newTokenHash,
|
|
||||||
deviceId: deviceInfo?.deviceId,
|
|
||||||
userAgent: deviceInfo?.userAgent,
|
|
||||||
createdAt,
|
|
||||||
absoluteExpiresAt,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
pipeline.setex(tokenKey, ttlSeconds, JSON.stringify({ familyId, userId, valid: true }));
|
|
||||||
pipeline.sadd(userFamilySetKey, familyId);
|
|
||||||
pipeline.expire(userFamilySetKey, ttlSeconds);
|
|
||||||
await pipeline.exec();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a token by hash
|
* Delete a token by hash
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -84,28 +84,9 @@ export class AuthTokenService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke a specific refresh token
|
* Revoke all refresh tokens for a user.
|
||||||
*/
|
* Intentionally skips `checkServiceAvailability()` — logout must succeed
|
||||||
async revokeRefreshToken(refreshToken: string): Promise<void> {
|
* even during maintenance windows so users are never locked into a session.
|
||||||
return this.revocation.revokeRefreshToken(refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all active refresh token families for a user
|
|
||||||
*/
|
|
||||||
async getUserRefreshTokenFamilies(userId: string): Promise<
|
|
||||||
Array<{
|
|
||||||
familyId: string;
|
|
||||||
deviceId?: string | undefined;
|
|
||||||
userAgent?: string | undefined;
|
|
||||||
createdAt?: string | undefined;
|
|
||||||
}>
|
|
||||||
> {
|
|
||||||
return this.revocation.getUserRefreshTokenFamilies(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke all refresh tokens for a user
|
|
||||||
*/
|
*/
|
||||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||||
return this.revocation.revokeAllUserTokens(userId);
|
return this.revocation.revokeAllUserTokens(userId);
|
||||||
|
|||||||
@ -38,26 +38,32 @@ export interface CreateWhmcsClientResult {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateWhmcsClientStep {
|
export class CreateWhmcsClientStep {
|
||||||
|
private readonly customerNumberFieldId: string | undefined;
|
||||||
|
private readonly dobFieldId: string | undefined;
|
||||||
|
private readonly genderFieldId: string | undefined;
|
||||||
|
private readonly nationalityFieldId: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly configService: ConfigService,
|
configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {
|
||||||
|
this.customerNumberFieldId = configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
|
||||||
|
this.dobFieldId = configService.get<string>("WHMCS_DOB_FIELD_ID");
|
||||||
|
this.genderFieldId = configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||||
|
this.nationalityFieldId = configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||||
|
}
|
||||||
|
|
||||||
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
|
async execute(params: CreateWhmcsClientParams): Promise<CreateWhmcsClientResult> {
|
||||||
const customerNumberFieldId = this.configService.get<string>("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
|
|
||||||
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
|
||||||
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
|
||||||
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
|
||||||
|
|
||||||
const customfieldsMap: Record<string, string> = {};
|
const customfieldsMap: Record<string, string> = {};
|
||||||
if (customerNumberFieldId && params.customerNumber) {
|
if (this.customerNumberFieldId && params.customerNumber) {
|
||||||
customfieldsMap[customerNumberFieldId] = params.customerNumber;
|
customfieldsMap[this.customerNumberFieldId] = params.customerNumber;
|
||||||
}
|
}
|
||||||
if (dobFieldId && params.dateOfBirth) customfieldsMap[dobFieldId] = params.dateOfBirth;
|
if (this.dobFieldId && params.dateOfBirth)
|
||||||
if (genderFieldId && params.gender) customfieldsMap[genderFieldId] = params.gender;
|
customfieldsMap[this.dobFieldId] = params.dateOfBirth;
|
||||||
if (nationalityFieldId && params.nationality)
|
if (this.genderFieldId && params.gender) customfieldsMap[this.genderFieldId] = params.gender;
|
||||||
customfieldsMap[nationalityFieldId] = params.nationality;
|
if (this.nationalityFieldId && params.nationality)
|
||||||
|
customfieldsMap[this.nationalityFieldId] = params.nationality;
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
{
|
{
|
||||||
@ -102,9 +108,7 @@ export class CreateWhmcsClientStep {
|
|||||||
},
|
},
|
||||||
"Failed to create WHMCS client"
|
"Failed to create WHMCS client"
|
||||||
);
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Failed to create billing account");
|
||||||
`Failed to create billing account: ${extractErrorMessage(whmcsError)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
|
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
|
||||||
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
|
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
|
||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser, RequestWithCookies } from "@bff/modules/auth/auth.types.js";
|
||||||
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
||||||
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
|
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
|
||||||
import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js";
|
import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js";
|
||||||
@ -59,11 +59,6 @@ import {
|
|||||||
loginVerifyOtpRequestSchema,
|
loginVerifyOtpRequestSchema,
|
||||||
} from "@customer-portal/domain/auth";
|
} from "@customer-portal/domain/auth";
|
||||||
|
|
||||||
type CookieValue = string | undefined;
|
|
||||||
type RequestWithCookies = Omit<Request, "cookies"> & {
|
|
||||||
cookies?: Record<string, CookieValue>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-export for backward compatibility with tests
|
// Re-export for backward compatibility with tests
|
||||||
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
|
import { AuthLoginService } from "@bff/modules/auth/application/auth-login.service.js";
|
||||||
import { ErrorCode } from "@customer-portal/domain/common";
|
import { ErrorCode } from "@customer-portal/domain/common";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalAuthGuard implements CanActivate {
|
export class LocalAuthGuard implements CanActivate {
|
||||||
constructor(private readonly authOrchestrator: AuthOrchestrator) {}
|
constructor(private readonly loginService: AuthLoginService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
@ -22,7 +22,7 @@ export class LocalAuthGuard implements CanActivate {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.authOrchestrator.validateUser(email, password, request);
|
const user = await this.loginService.validateUser(email, password, request);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException({
|
throw new UnauthorizedException({
|
||||||
message: "Invalid credentials",
|
message: "Invalid credentials",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Response } from "express";
|
||||||
|
import type { RequestWithCookies } from "@bff/modules/auth/auth.types.js";
|
||||||
import { getSecureCookie } from "./secure-cookie.util.js";
|
import { getSecureCookie } from "./secure-cookie.util.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,13 +68,6 @@ export function clearTrustedDeviceCookie(res: Response): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request type with cookies
|
|
||||||
*/
|
|
||||||
type RequestWithCookies = Omit<Request, "cookies"> & {
|
|
||||||
cookies?: Record<string, string | undefined>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the trusted device token from the request cookies
|
* Get the trusted device token from the request cookies
|
||||||
*
|
*
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export * from "./http/auth.controller.js";
|
|
||||||
@ -8,7 +8,6 @@ import { TokenRevocationService } from "../infra/token/token-revocation.service.
|
|||||||
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
||||||
import { AuthTokenService } from "../infra/token/token.service.js";
|
import { AuthTokenService } from "../infra/token/token.service.js";
|
||||||
import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js";
|
import { PasswordResetTokenService } from "../infra/token/password-reset-token.service.js";
|
||||||
import { TokenMigrationService } from "../infra/token/token-migration.service.js";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule],
|
||||||
@ -21,15 +20,7 @@ import { TokenMigrationService } from "../infra/token/token-migration.service.js
|
|||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
AuthTokenService,
|
AuthTokenService,
|
||||||
PasswordResetTokenService,
|
PasswordResetTokenService,
|
||||||
TokenMigrationService,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
JoseJwtService,
|
|
||||||
AuthTokenService,
|
|
||||||
TokenBlacklistService,
|
|
||||||
TokenRefreshService,
|
|
||||||
PasswordResetTokenService,
|
|
||||||
TokenMigrationService,
|
|
||||||
],
|
],
|
||||||
|
exports: [JoseJwtService, AuthTokenService, TokenBlacklistService, PasswordResetTokenService],
|
||||||
})
|
})
|
||||||
export class TokensModule {}
|
export class TokensModule {}
|
||||||
|
|||||||
9
apps/bff/src/modules/auth/utils/hash-token.util.ts
Normal file
9
apps/bff/src/modules/auth/utils/hash-token.util.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 hash a token string to a hex digest.
|
||||||
|
* Used consistently for refresh token hashing and blacklist keys.
|
||||||
|
*/
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
@ -51,12 +51,9 @@ export const parseJwtExpiry = (expiresIn: string | number | undefined | null): n
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const calculateExpiryDate = (
|
/**
|
||||||
expiresIn: string | number | undefined | null,
|
* Parse a JWT expiry configuration string into milliseconds.
|
||||||
referenceDate: number = Date.now()
|
* Convenience wrapper over `parseJwtExpiry`.
|
||||||
): string => {
|
*/
|
||||||
const seconds = parseJwtExpiry(expiresIn);
|
export const parseJwtExpiryMs = (expiresIn: string | number | undefined | null): number =>
|
||||||
return new Date(referenceDate + seconds * 1000).toISOString();
|
parseJwtExpiry(expiresIn) * 1000;
|
||||||
};
|
|
||||||
|
|
||||||
export const JWT_EXPIRY_DEFAULT_SECONDS = DEFAULT_JWT_EXPIRY_SECONDS;
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user