feat(auth): implement permission-based access control and centralized error handling
- Introduced PermissionsGuard to enforce permission checks on routes. - Added RequirePermissions decorator for specifying required permissions on handlers. - Created AUTH_ERRORS constants for consistent error messages across the auth module. - Updated CsrfService to reduce CSRF token expiry time for enhanced security. - Refactored auth cookie handling into utility functions for better maintainability. - Enhanced TokenBlacklistService to default to fail-closed in production environments. - Updated various DTOs and schemas for consistency and clarity. - Removed legacy code and types related to SIM requests. - Improved logging and error handling in GlobalAuthGuard. - Added middleware for public path checks and optimistic authentication.
This commit is contained in:
parent
b52b2874d6
commit
d3b94b1ed3
@ -16,7 +16,7 @@ export const envSchema = z.object({
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(12).max(16).default(14),
|
||||
APP_BASE_URL: z.string().url().default("http://localhost:3000"),
|
||||
|
||||
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(3600000),
|
||||
CSRF_TOKEN_EXPIRY: z.coerce.number().int().positive().default(900000), // 15 minutes
|
||||
CSRF_SECRET_KEY: z.string().min(32, "CSRF secret key must be at least 32 characters").optional(),
|
||||
CSRF_COOKIE_NAME: z.string().default("csrf-secret"),
|
||||
CSRF_HEADER_NAME: z.string().default("X-CSRF-Token"),
|
||||
@ -46,7 +46,8 @@ export const envSchema = z.object({
|
||||
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
||||
AUTH_ALLOW_REDIS_TOKEN_FAILOPEN: z.enum(["true", "false"]).default("false"),
|
||||
AUTH_REQUIRE_REDIS_FOR_TOKENS: z.enum(["true", "false"]).default("false"),
|
||||
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).default("false"),
|
||||
// Default handled by service: fail-closed in production, fail-open in development
|
||||
AUTH_BLACKLIST_FAIL_CLOSED: z.enum(["true", "false"]).optional(),
|
||||
AUTH_MAINTENANCE_MODE: z.enum(["true", "false"]).default("false"),
|
||||
AUTH_MAINTENANCE_MESSAGE: z
|
||||
.string()
|
||||
|
||||
@ -140,13 +140,6 @@ export class CsrfMiddleware implements NestMiddleware {
|
||||
// Store validated token in request for potential use by controllers
|
||||
req.csrfToken = token;
|
||||
|
||||
this.logger.debug("CSRF validation successful", {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,8 @@ export class CsrfService {
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "3600000"));
|
||||
// Default to 15 minutes (900000ms) - shorter TTL reduces CSRF attack window
|
||||
this.tokenExpiry = Number(this.configService.get("CSRF_TOKEN_EXPIRY", "900000"));
|
||||
this.secretKey = this.configService.get("CSRF_SECRET_KEY") || this.generateSecretKey();
|
||||
this.cookieName = this.configService.get("CSRF_COOKIE_NAME", "csrf-secret");
|
||||
this.headerName = this.configService.get("CSRF_HEADER_NAME", "X-CSRF-Token");
|
||||
|
||||
@ -8,6 +8,7 @@ import { UsersModule } from "@bff/modules/users/users.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||
import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js";
|
||||
import { PermissionsGuard } from "./presentation/http/guards/permissions.guard.js";
|
||||
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
||||
import { TokenStorageService } from "./infra/token/token-storage.service.js";
|
||||
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
||||
@ -68,7 +69,11 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
||||
provide: APP_GUARD,
|
||||
useClass: GlobalAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PermissionsGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthFacade, TokenBlacklistService, AuthTokenService],
|
||||
exports: [AuthFacade, TokenBlacklistService, AuthTokenService, PermissionsGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
115
apps/bff/src/modules/auth/constants/auth-errors.ts
Normal file
115
apps/bff/src/modules/auth/constants/auth-errors.ts
Normal file
@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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];
|
||||
@ -0,0 +1,25 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import type { Permission } from "@customer-portal/domain/auth";
|
||||
|
||||
export const REQUIRED_PERMISSIONS_KEY = "requiredPermissions";
|
||||
|
||||
/**
|
||||
* Decorator to require specific permissions for a route.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @RequirePermissions(PERMISSIONS.ADMIN_USERS)
|
||||
* @Get('admin/users')
|
||||
* listUsers() { ... }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Require any of the specified permissions
|
||||
* @RequirePermissions(PERMISSIONS.BILLING_READ, PERMISSIONS.ADMIN_AUDIT)
|
||||
* @Get('billing/summary')
|
||||
* getBillingSummary() { ... }
|
||||
* ```
|
||||
*/
|
||||
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||
SetMetadata(REQUIRED_PERMISSIONS_KEY, permissions);
|
||||
@ -16,7 +16,14 @@ export class TokenBlacklistService {
|
||||
private readonly jwtService: JoseJwtService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.failClosed = this.configService.get("AUTH_BLACKLIST_FAIL_CLOSED", "false") === "true";
|
||||
// Default to fail-closed in production for security, fail-open in development for convenience
|
||||
const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
|
||||
const explicitSetting = this.configService.get<string>("AUTH_BLACKLIST_FAIL_CLOSED");
|
||||
if (explicitSetting === undefined) {
|
||||
this.failClosed = nodeEnv === "production";
|
||||
} else {
|
||||
this.failClosed = explicitSetting === "true";
|
||||
}
|
||||
}
|
||||
|
||||
async blacklistToken(token: string): Promise<void> {
|
||||
|
||||
@ -25,6 +25,14 @@ import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guard
|
||||
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
||||
import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
|
||||
import {
|
||||
setAuthCookies,
|
||||
clearAuthCookies,
|
||||
buildSessionInfo,
|
||||
ACCESS_COOKIE_PATH,
|
||||
REFRESH_COOKIE_PATH,
|
||||
TOKEN_TYPE,
|
||||
} from "./utils/auth-cookie.util.js";
|
||||
|
||||
// Import Zod schemas from domain
|
||||
import {
|
||||
@ -47,6 +55,9 @@ type RequestWithCookies = Omit<Request, "cookies"> & {
|
||||
cookies?: Record<string, CookieValue>;
|
||||
};
|
||||
|
||||
// Re-export for backward compatibility with tests
|
||||
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
||||
|
||||
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
|
||||
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
||||
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
||||
@ -60,18 +71,6 @@ class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
|
||||
class LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
|
||||
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
|
||||
|
||||
const calculateCookieMaxAge = (isoTimestamp: string): number => {
|
||||
const expiresAt = Date.parse(isoTimestamp);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, expiresAt - Date.now());
|
||||
};
|
||||
|
||||
const ACCESS_COOKIE_PATH = "/api";
|
||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||
const TOKEN_TYPE = "Bearer" as const;
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
@ -79,46 +78,6 @@ export class AuthController {
|
||||
private readonly jwtService: JoseJwtService
|
||||
) {}
|
||||
|
||||
private setAuthCookies(
|
||||
res: Response,
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
}
|
||||
): void {
|
||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
|
||||
|
||||
res.setSecureCookie("access_token", tokens.accessToken, {
|
||||
maxAge: accessMaxAge,
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
});
|
||||
res.setSecureCookie("refresh_token", tokens.refreshToken, {
|
||||
maxAge: refreshMaxAge,
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
private toSession(tokens: { expiresAt: string; refreshExpiresAt: string }) {
|
||||
return {
|
||||
expiresAt: tokens.expiresAt,
|
||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
||||
tokenType: TOKEN_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
private clearAuthCookies(res: Response): void {
|
||||
// Clear current cookie paths
|
||||
res.setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
||||
|
||||
// Backward-compat: clear legacy cookies that were set on `/`
|
||||
res.setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
||||
res.setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||
}
|
||||
|
||||
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
||||
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
||||
}
|
||||
@ -145,8 +104,8 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.signup(signupData, req);
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -158,9 +117,9 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.login(req.user, req);
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
setAuthCookies(res, result.tokens);
|
||||
this.applyAuthRateLimitHeaders(req, res);
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -182,7 +141,7 @@ export class AuthController {
|
||||
await this.authFacade.logout(userId, token, req as Request);
|
||||
|
||||
// Always clear cookies, even if session expired
|
||||
this.clearAuthCookies(res);
|
||||
clearAuthCookies(res);
|
||||
return { message: "Logout successful" };
|
||||
}
|
||||
|
||||
@ -202,8 +161,8 @@ export class AuthController {
|
||||
deviceId: body.deviceId ?? undefined,
|
||||
userAgent: userAgent ?? undefined,
|
||||
});
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -226,8 +185,8 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.setPassword(setPasswordData);
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@ -264,7 +223,7 @@ export class AuthController {
|
||||
await this.authFacade.resetPassword(body.token, body.password);
|
||||
|
||||
// Clear auth cookies after password reset to force re-login
|
||||
this.clearAuthCookies(res);
|
||||
clearAuthCookies(res);
|
||||
return { message: "Password reset successful" };
|
||||
}
|
||||
|
||||
@ -277,8 +236,8 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
const result = await this.authFacade.changePassword(req.user.id, body, req);
|
||||
this.setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
setAuthCookies(res, result.tokens);
|
||||
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
|
||||
import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js";
|
||||
import { setAuthCookies, buildSessionInfo, type SessionInfo } from "./utils/auth-cookie.util.js";
|
||||
|
||||
// DTO classes using Zod schemas
|
||||
class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {}
|
||||
@ -32,72 +33,11 @@ class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchem
|
||||
class SignupWithEligibilityRequestDto extends createZodDto(signupWithEligibilityRequestSchema) {}
|
||||
class SignupWithEligibilityResponseDto extends createZodDto(signupWithEligibilityResponseSchema) {}
|
||||
|
||||
// Cookie configuration
|
||||
const ACCESS_COOKIE_PATH = "/api";
|
||||
const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||
const TOKEN_TYPE = "Bearer" as const;
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
tokenType: typeof TOKEN_TYPE;
|
||||
}
|
||||
|
||||
interface AuthSuccessResponse {
|
||||
user: User;
|
||||
session: SessionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cookie max age from ISO timestamp
|
||||
*/
|
||||
function calculateCookieMaxAge(isoTimestamp: string): number {
|
||||
const expiresAt = Date.parse(isoTimestamp);
|
||||
if (Number.isNaN(expiresAt)) return 0;
|
||||
return Math.max(0, expiresAt - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication cookies (httpOnly, secure in production)
|
||||
*/
|
||||
function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
res.cookie("access_token", tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(tokens.expiresAt),
|
||||
});
|
||||
|
||||
res.cookie("refresh_token", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: calculateCookieMaxAge(tokens.refreshExpiresAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build session info from tokens
|
||||
*/
|
||||
function buildSessionInfo(tokens: AuthTokens): SessionInfo {
|
||||
return {
|
||||
expiresAt: tokens.expiresAt,
|
||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
||||
tokenType: TOKEN_TYPE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Started Controller
|
||||
*
|
||||
|
||||
@ -55,22 +55,17 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
if (isPublicNoSession) {
|
||||
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (token) {
|
||||
try {
|
||||
await this.attachUserFromToken(request, token);
|
||||
this.logger.debug(`Authenticated session detected on public route: ${route}`);
|
||||
} catch {
|
||||
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
|
||||
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
|
||||
if (!isPublicNoSession) {
|
||||
// Try to attach user from token if present (optional)
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (token) {
|
||||
try {
|
||||
await this.attachUserFromToken(request, token);
|
||||
} catch {
|
||||
// Public endpoints should remain accessible even if the session is missing/expired/invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Public route accessed: ${route}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -85,48 +80,35 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (!token) {
|
||||
// No token = not logged in, allow request with no user attached
|
||||
this.logger.debug(`Optional auth route accessed without token: ${route}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Token present - validate it strictly (invalid token = 401)
|
||||
try {
|
||||
await this.attachUserFromToken(request, token, route);
|
||||
this.logger.debug(`Optional auth route accessed with valid token: ${route}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Token is invalid/expired - return 401 to signal "session expired"
|
||||
this.logger.debug(`Optional auth route - invalid token, returning 401: ${route}`);
|
||||
throw error;
|
||||
}
|
||||
await this.attachUserFromToken(request, token, route);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (!token) {
|
||||
if (isLogoutRoute) {
|
||||
this.logger.debug(`Allowing logout request without active session: ${route}`);
|
||||
return true;
|
||||
}
|
||||
throw new UnauthorizedException("Missing token");
|
||||
}
|
||||
|
||||
await this.attachUserFromToken(request, token, route);
|
||||
|
||||
this.logger.debug(`Authenticated access to: ${route}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
if (isLogoutRoute) {
|
||||
this.logger.debug(`Allowing logout request with expired or invalid token: ${route}`);
|
||||
return true;
|
||||
}
|
||||
// Only log warnings for requests with an invalid token (not missing tokens)
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
const log =
|
||||
typeof token === "string"
|
||||
? () => this.logger.warn(`Unauthorized access attempt to ${route}`)
|
||||
: () => this.logger.debug(`Unauthenticated request blocked for ${route}`);
|
||||
log();
|
||||
if (typeof token === "string") {
|
||||
this.logger.warn(`Unauthorized access attempt to ${route}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Authentication error for route ${route}: ${extractErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import { Injectable, ForbiddenException, Logger } from "@nestjs/common";
|
||||
import type { CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { REQUIRED_PERMISSIONS_KEY } from "../../../decorators/permissions.decorator.js";
|
||||
import { hasAnyPermission, type Permission } from "@customer-portal/domain/auth";
|
||||
|
||||
interface UserWithRole {
|
||||
id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
type RequestWithUser = Request & { user?: UserWithRole };
|
||||
|
||||
/**
|
||||
* Guard to enforce permission-based access control.
|
||||
*
|
||||
* This guard checks if the authenticated user has any of the required
|
||||
* permissions specified via the @RequirePermissions decorator.
|
||||
*
|
||||
* Note: This guard must be used after the GlobalAuthGuard, which
|
||||
* attaches the user to the request.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
private readonly logger = new Logger(PermissionsGuard.name);
|
||||
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<Permission[] | undefined>(
|
||||
REQUIRED_PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()]
|
||||
);
|
||||
|
||||
// No permissions required - allow access
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
this.logger.warn("Permissions check failed - no user on request");
|
||||
throw new ForbiddenException("Access denied");
|
||||
}
|
||||
|
||||
if (!user.role) {
|
||||
this.logger.warn("User has no role assigned", { userId: user.id });
|
||||
throw new ForbiddenException("User role not configured");
|
||||
}
|
||||
|
||||
const hasAccess = hasAnyPermission(user.role, requiredPermissions);
|
||||
|
||||
if (!hasAccess) {
|
||||
this.logger.warn("Permission denied", {
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
requiredPermissions,
|
||||
});
|
||||
throw new ForbiddenException("Insufficient permissions");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import type { Response, CookieOptions } from "express";
|
||||
|
||||
/**
|
||||
* Auth tokens structure for cookie setting
|
||||
*/
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session info returned to client (excludes actual tokens)
|
||||
*/
|
||||
export interface SessionInfo {
|
||||
expiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
tokenType: "Bearer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom setSecureCookie function signature
|
||||
* This is added by our security middleware
|
||||
*/
|
||||
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
|
||||
|
||||
/**
|
||||
* Get setSecureCookie function from response if available
|
||||
* Returns null if the custom helper is not present
|
||||
*/
|
||||
function getSecureCookie(res: Response): SetSecureCookieFn | null {
|
||||
const maybeSecure = res as Response & { setSecureCookie?: unknown };
|
||||
if (typeof maybeSecure.setSecureCookie === "function") {
|
||||
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cookie paths - access token needs broader access, refresh token only for refresh endpoint
|
||||
export const ACCESS_COOKIE_PATH = "/api";
|
||||
export const REFRESH_COOKIE_PATH = "/api/auth/refresh";
|
||||
export const TOKEN_TYPE = "Bearer" as const;
|
||||
|
||||
/**
|
||||
* Calculate cookie max age from ISO timestamp
|
||||
*/
|
||||
export function calculateCookieMaxAge(isoTimestamp: string): number {
|
||||
const expiresAt = Date.parse(isoTimestamp);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, expiresAt - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication cookies on the response.
|
||||
*
|
||||
* Uses res.setSecureCookie if available (custom helper that sets
|
||||
* httpOnly, secure, sameSite), otherwise falls back to standard res.cookie.
|
||||
*/
|
||||
export function setAuthCookies(res: Response, tokens: AuthTokens): void {
|
||||
const accessMaxAge = calculateCookieMaxAge(tokens.expiresAt);
|
||||
const refreshMaxAge = calculateCookieMaxAge(tokens.refreshExpiresAt);
|
||||
|
||||
const setSecureCookie = getSecureCookie(res);
|
||||
if (setSecureCookie) {
|
||||
// Use the custom setSecureCookie helper if available
|
||||
setSecureCookie("access_token", tokens.accessToken, {
|
||||
maxAge: accessMaxAge,
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
});
|
||||
setSecureCookie("refresh_token", tokens.refreshToken, {
|
||||
maxAge: refreshMaxAge,
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard cookie with secure options
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
res.cookie("access_token", tokens.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: ACCESS_COOKIE_PATH,
|
||||
maxAge: accessMaxAge,
|
||||
});
|
||||
|
||||
res.cookie("refresh_token", tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: REFRESH_COOKIE_PATH,
|
||||
maxAge: refreshMaxAge,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication cookies from the response.
|
||||
* Also clears legacy cookies that may have been set on "/" path.
|
||||
*/
|
||||
export function clearAuthCookies(res: Response): void {
|
||||
const setSecureCookie = getSecureCookie(res);
|
||||
if (setSecureCookie) {
|
||||
// Clear current cookie paths
|
||||
setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH });
|
||||
setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH });
|
||||
|
||||
// DEPRECATED: Clear legacy cookies that were set on `/`
|
||||
// TODO: Remove after 90 days (2025-04-XX) when legacy cookies have expired
|
||||
setSecureCookie("access_token", "", { maxAge: 0, path: "/" });
|
||||
setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" });
|
||||
} else {
|
||||
// Fallback to standard cookie clearing
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
const clearOptions = {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax" as const,
|
||||
maxAge: 0,
|
||||
};
|
||||
|
||||
res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH });
|
||||
res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH });
|
||||
|
||||
// DEPRECATED: Clear legacy cookies
|
||||
res.cookie("access_token", "", { ...clearOptions, path: "/" });
|
||||
res.cookie("refresh_token", "", { ...clearOptions, path: "/" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build session info from tokens (for client response, excludes actual tokens)
|
||||
*/
|
||||
export function buildSessionInfo(tokens: AuthTokens): SessionInfo {
|
||||
return {
|
||||
expiresAt: tokens.expiresAt,
|
||||
refreshExpiresAt: tokens.refreshExpiresAt,
|
||||
tokenType: TOKEN_TYPE,
|
||||
};
|
||||
}
|
||||
@ -25,10 +25,10 @@ import {
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { SimActionResponse, SimPlanChangeResult } from "@customer-portal/domain/subscriptions";
|
||||
import {
|
||||
simTopupRequestSchema,
|
||||
simChangePlanRequestSchema,
|
||||
simTopUpRequestSchema,
|
||||
simPlanChangeRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simFeaturesUpdateRequestSchema,
|
||||
simTopUpHistoryRequestSchema,
|
||||
simChangePlanFullRequestSchema,
|
||||
simCancelFullRequestSchema,
|
||||
@ -49,10 +49,10 @@ import {
|
||||
|
||||
// DTOs
|
||||
class SubscriptionIdParamDto extends createZodDto(subscriptionIdParamSchema) {}
|
||||
class SimTopupRequestDto extends createZodDto(simTopupRequestSchema) {}
|
||||
class SimChangePlanRequestDto extends createZodDto(simChangePlanRequestSchema) {}
|
||||
class SimTopUpRequestDto extends createZodDto(simTopUpRequestSchema) {}
|
||||
class SimPlanChangeRequestDto extends createZodDto(simPlanChangeRequestSchema) {}
|
||||
class SimCancelRequestDto extends createZodDto(simCancelRequestSchema) {}
|
||||
class SimFeaturesRequestDto extends createZodDto(simFeaturesRequestSchema) {}
|
||||
class SimFeaturesUpdateRequestDto extends createZodDto(simFeaturesUpdateRequestSchema) {}
|
||||
class SimChangePlanFullRequestDto extends createZodDto(simChangePlanFullRequestSchema) {}
|
||||
class SimCancelFullRequestDto extends createZodDto(simCancelFullRequestSchema) {}
|
||||
class SimReissueFullRequestDto extends createZodDto(simReissueFullRequestSchema) {}
|
||||
@ -155,7 +155,7 @@ export class SimController {
|
||||
async topUpSim(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Body() body: SimTopupRequestDto
|
||||
@Body() body: SimTopUpRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.topUpSim(req.user.id, params.id, body);
|
||||
return { message: "SIM top-up completed successfully" };
|
||||
@ -166,7 +166,7 @@ export class SimController {
|
||||
async changeSimPlan(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Body() body: SimChangePlanRequestDto
|
||||
@Body() body: SimPlanChangeRequestDto
|
||||
): Promise<SimPlanChangeResult> {
|
||||
const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body);
|
||||
return {
|
||||
@ -202,7 +202,7 @@ export class SimController {
|
||||
async updateSimFeatures(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param() params: SubscriptionIdParamDto,
|
||||
@Body() body: SimFeaturesRequestDto
|
||||
@Body() body: SimFeaturesUpdateRequestDto
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.updateSimFeatures(req.user.id, params.id, body);
|
||||
return { message: "SIM features updated successfully" };
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
export interface SimTopUpRequest {
|
||||
quotaMb: number;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export interface SimPlanChangeRequest {
|
||||
newPlanCode: "5GB" | "10GB" | "25GB" | "50GB";
|
||||
effectiveDate?: string;
|
||||
}
|
||||
|
||||
export interface SimCancelRequest {
|
||||
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
|
||||
}
|
||||
|
||||
export interface SimTopUpHistoryRequest {
|
||||
fromDate: string; // YYYYMMDD
|
||||
toDate: string; // YYYYMMDD
|
||||
}
|
||||
|
||||
export interface SimFeaturesUpdateRequest {
|
||||
voiceMailEnabled?: boolean;
|
||||
callWaitingEnabled?: boolean;
|
||||
internationalRoamingEnabled?: boolean;
|
||||
networkType?: "4G" | "5G";
|
||||
}
|
||||
@ -9,7 +9,12 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuthStore } from "../stores/auth.store";
|
||||
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
|
||||
import type { SignupRequest, LoginRequest } from "@customer-portal/domain/auth";
|
||||
import type { SignupRequest, LoginRequest, Permission } from "@customer-portal/domain/auth";
|
||||
import {
|
||||
PERMISSIONS,
|
||||
hasPermission as checkPermission,
|
||||
hasAnyPermission as checkAnyPermission,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import type { LogoutReason } from "@/features/auth/utils/logout-reason";
|
||||
|
||||
/**
|
||||
@ -279,22 +284,34 @@ export function useUser() {
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { user } = useAuth();
|
||||
const role = user?.role ?? "USER";
|
||||
|
||||
const hasRole = useCallback(
|
||||
(role: string) => {
|
||||
(targetRole: string) => {
|
||||
if (!user?.role) return false;
|
||||
return user.role === role;
|
||||
return user.role === targetRole;
|
||||
},
|
||||
[user?.role]
|
||||
);
|
||||
|
||||
const hasAnyRole = useCallback((roles: string[]) => roles.some(role => hasRole(role)), [hasRole]);
|
||||
const hasAnyRole = useCallback(
|
||||
(roles: string[]) => roles.some(targetRole => hasRole(targetRole)),
|
||||
[hasRole]
|
||||
);
|
||||
|
||||
const hasPermission = useCallback(() => false, []);
|
||||
const hasAnyPermission = useCallback(() => false, []);
|
||||
const hasPermission = useCallback(
|
||||
(permission: Permission) => checkPermission(role, permission),
|
||||
[role]
|
||||
);
|
||||
|
||||
const hasAnyPermission = useCallback(
|
||||
(permissions: Permission[]) => checkAnyPermission(role, permissions),
|
||||
[role]
|
||||
);
|
||||
|
||||
return {
|
||||
role: user?.role,
|
||||
permissions: PERMISSIONS,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasPermission,
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { normalizeOrderType } from "@customer-portal/domain/checkout";
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
buildOrderConfigurations,
|
||||
@ -35,10 +34,12 @@ export class CheckoutParamsService {
|
||||
return ORDER_TYPE.INTERNET;
|
||||
}
|
||||
|
||||
// Try to normalize using domain logic
|
||||
const normalized = normalizeOrderType(typeParam);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
// Case-insensitive match against valid order types
|
||||
const validTypes = Object.values(ORDER_TYPE) as string[];
|
||||
const matchedType = validTypes.find(type => type.toLowerCase() === typeParam.toLowerCase());
|
||||
|
||||
if (matchedType) {
|
||||
return matchedType as OrderTypeValue;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported order type: ${typeParam}`);
|
||||
|
||||
@ -104,31 +104,8 @@ export const useCheckoutStore = create<CheckoutStore>()(
|
||||
}),
|
||||
{
|
||||
name: "checkout-store",
|
||||
version: 3,
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
if (!persistedState || typeof persistedState !== "object") {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
const state = persistedState as Partial<CheckoutState>;
|
||||
|
||||
// Migration from v1/v2: strip out removed fields
|
||||
if (version < 3) {
|
||||
return {
|
||||
cartItem: state.cartItem ?? null,
|
||||
cartParamsSignature: state.cartParamsSignature ?? null,
|
||||
checkoutSessionId: state.checkoutSessionId ?? null,
|
||||
checkoutSessionExpiresAt: state.checkoutSessionExpiresAt ?? null,
|
||||
cartUpdatedAt: state.cartUpdatedAt ?? null,
|
||||
} as CheckoutState;
|
||||
}
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
...state,
|
||||
} as CheckoutState;
|
||||
},
|
||||
partialize: state => ({
|
||||
// Persist only essential data
|
||||
cartItem: state.cartItem
|
||||
|
||||
@ -91,7 +91,7 @@ export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
as="a"
|
||||
href={`/order?type=vpn&planSku=${encodeURIComponent(plan.sku)}`}
|
||||
href={`/order?type=VPN&planSku=${encodeURIComponent(plan.sku)}`}
|
||||
className="w-full"
|
||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||
>
|
||||
|
||||
67
apps/portal/src/middleware.ts
Normal file
67
apps/portal/src/middleware.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Public paths that don't require authentication.
|
||||
* These routes are accessible without auth cookies.
|
||||
*/
|
||||
const PUBLIC_PATHS = ["/auth/", "/", "/services", "/about", "/contact", "/vpn-configuration"];
|
||||
|
||||
/**
|
||||
* Check if a path is public (doesn't require authentication)
|
||||
*/
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some(publicPath => {
|
||||
if (publicPath.endsWith("/")) {
|
||||
// Path prefix match (e.g., /auth/ matches /auth/login)
|
||||
return pathname.startsWith(publicPath);
|
||||
}
|
||||
// Exact match
|
||||
return pathname === publicPath;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge middleware for optimistic auth checks.
|
||||
*
|
||||
* This middleware runs at the edge before requests reach the origin.
|
||||
* It performs a fast cookie-based check to redirect unauthenticated
|
||||
* users to the login page, reducing unnecessary BFF calls.
|
||||
*
|
||||
* Note: This is an optimistic check. The BFF performs the authoritative
|
||||
* token validation. This middleware only checks for cookie presence.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Allow public routes
|
||||
if (isPublicPath(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check for auth cookies (optimistic check - BFF validates the token)
|
||||
const hasAccessToken = request.cookies.has("access_token");
|
||||
const hasRefreshToken = request.cookies.has("refresh_token");
|
||||
|
||||
if (!hasAccessToken && !hasRefreshToken) {
|
||||
// No auth cookies present - redirect to login
|
||||
const loginUrl = new URL("/auth/login", request.url);
|
||||
loginUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public assets (images, fonts, etc.)
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot)$).*)",
|
||||
],
|
||||
};
|
||||
@ -105,3 +105,17 @@ export {
|
||||
MIGRATION_STEPS,
|
||||
type PasswordRequirementKey,
|
||||
} from "./forms.js";
|
||||
|
||||
// ============================================================================
|
||||
// RBAC Permissions
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
PERMISSIONS,
|
||||
ROLE_PERMISSIONS,
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
hasAllPermissions,
|
||||
getPermissionsForRole,
|
||||
type Permission,
|
||||
} from "./permissions.js";
|
||||
|
||||
82
packages/domain/auth/permissions.ts
Normal file
82
packages/domain/auth/permissions.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* RBAC Permissions
|
||||
*
|
||||
* Defines the permission constants and role-permission mappings
|
||||
* for the customer portal authorization system.
|
||||
*/
|
||||
|
||||
export const PERMISSIONS = {
|
||||
// Account permissions
|
||||
ACCOUNT_READ: "account:read",
|
||||
ACCOUNT_UPDATE: "account:update",
|
||||
|
||||
// Billing permissions
|
||||
BILLING_READ: "billing:read",
|
||||
BILLING_PAY: "billing:pay",
|
||||
|
||||
// Orders permissions
|
||||
ORDERS_READ: "orders:read",
|
||||
ORDERS_CREATE: "orders:create",
|
||||
|
||||
// Services permissions
|
||||
SERVICES_READ: "services:read",
|
||||
SERVICES_MANAGE: "services:manage",
|
||||
|
||||
// Support permissions
|
||||
SUPPORT_READ: "support:read",
|
||||
SUPPORT_CREATE: "support:create",
|
||||
|
||||
// Admin permissions
|
||||
ADMIN_USERS: "admin:users",
|
||||
ADMIN_AUDIT: "admin:audit",
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
USER: [
|
||||
PERMISSIONS.ACCOUNT_READ,
|
||||
PERMISSIONS.ACCOUNT_UPDATE,
|
||||
PERMISSIONS.BILLING_READ,
|
||||
PERMISSIONS.BILLING_PAY,
|
||||
PERMISSIONS.ORDERS_READ,
|
||||
PERMISSIONS.ORDERS_CREATE,
|
||||
PERMISSIONS.SERVICES_READ,
|
||||
PERMISSIONS.SERVICES_MANAGE,
|
||||
PERMISSIONS.SUPPORT_READ,
|
||||
PERMISSIONS.SUPPORT_CREATE,
|
||||
],
|
||||
ADMIN: Object.values(PERMISSIONS) as Permission[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role has a specific permission
|
||||
*/
|
||||
export function hasPermission(role: string, permission: Permission): boolean {
|
||||
const rolePermissions = ROLE_PERMISSIONS[role];
|
||||
if (!rolePermissions) {
|
||||
return false;
|
||||
}
|
||||
return rolePermissions.includes(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has any of the specified permissions
|
||||
*/
|
||||
export function hasAnyPermission(role: string, permissions: Permission[]): boolean {
|
||||
return permissions.some(permission => hasPermission(role, permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has all of the specified permissions
|
||||
*/
|
||||
export function hasAllPermissions(role: string, permissions: Permission[]): boolean {
|
||||
return permissions.every(permission => hasPermission(role, permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a role
|
||||
*/
|
||||
export function getPermissionsForRole(role: string): Permission[] {
|
||||
return ROLE_PERMISSIONS[role] ?? [];
|
||||
}
|
||||
@ -19,25 +19,5 @@ export const CHECKOUT_ORDER_TYPE = {
|
||||
|
||||
export type CheckoutOrderTypeValue = (typeof CHECKOUT_ORDER_TYPE)[keyof typeof CHECKOUT_ORDER_TYPE];
|
||||
|
||||
/**
|
||||
* Convert legacy uppercase order type to PascalCase
|
||||
* Used for migrating old localStorage data
|
||||
*/
|
||||
export function normalizeOrderType(value: unknown): CheckoutOrderTypeValue | null {
|
||||
if (typeof value !== "string") return null;
|
||||
|
||||
const upper = value.trim().toUpperCase();
|
||||
switch (upper) {
|
||||
case "INTERNET":
|
||||
return "Internet";
|
||||
case "SIM":
|
||||
return "SIM";
|
||||
case "VPN":
|
||||
return "VPN";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types from schema
|
||||
export type { OrderType, PriceBreakdownItem, CartItem } from "./schema.js";
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
export {
|
||||
ORDER_TYPE,
|
||||
CHECKOUT_ORDER_TYPE,
|
||||
normalizeOrderType,
|
||||
type OrderTypeValue,
|
||||
type CheckoutOrderTypeValue,
|
||||
} from "./contract.js";
|
||||
|
||||
@ -28,7 +28,6 @@ export type {
|
||||
Address, // Address structure (not "CustomerAddress")
|
||||
AddressFormData, // Address form validation
|
||||
ProfileEditFormData, // Profile edit form data
|
||||
ProfileDisplayData, // Profile display data (alias)
|
||||
ResidenceCardVerificationStatus,
|
||||
ResidenceCardVerification,
|
||||
UserProfile, // Alias for User
|
||||
@ -46,7 +45,6 @@ export {
|
||||
addressSchema,
|
||||
addressFormSchema,
|
||||
profileEditFormSchema,
|
||||
profileDisplayDataSchema,
|
||||
residenceCardVerificationStatusSchema,
|
||||
residenceCardVerificationSchema,
|
||||
|
||||
|
||||
@ -95,14 +95,6 @@ export const profileEditFormSchema = z.object({
|
||||
phonenumber: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Profile display data - includes email for display (read-only)
|
||||
* Used for displaying profile information
|
||||
*/
|
||||
export const profileDisplayDataSchema = profileEditFormSchema.extend({
|
||||
// no extra fields (kept for backwards compatibility)
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// UserAuth Schema (Portal Database - Auth State Only)
|
||||
// ============================================================================
|
||||
@ -421,7 +413,6 @@ export type UserRole = "USER" | "ADMIN";
|
||||
export type Address = z.infer<typeof addressSchema>;
|
||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
||||
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
||||
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
|
||||
export type ResidenceCardVerificationStatus = z.infer<typeof residenceCardVerificationStatusSchema>;
|
||||
export type ResidenceCardVerification = z.infer<typeof residenceCardVerificationSchema>;
|
||||
|
||||
|
||||
@ -564,16 +564,6 @@ export type SimOrderActivationRequest = z.infer<typeof simOrderActivationRequest
|
||||
export type SimOrderActivationMnp = z.infer<typeof simOrderActivationMnpSchema>;
|
||||
export type SimOrderActivationAddons = z.infer<typeof simOrderActivationAddonsSchema>;
|
||||
|
||||
// Legacy aliases for backward compatibility
|
||||
export const simTopupRequestSchema = simTopUpRequestSchema;
|
||||
export type SimTopupRequest = SimTopUpRequest;
|
||||
|
||||
export const simChangePlanRequestSchema = simPlanChangeRequestSchema;
|
||||
export type SimChangePlanRequest = SimPlanChangeRequest;
|
||||
|
||||
export const simFeaturesRequestSchema = simFeaturesUpdateRequestSchema;
|
||||
export type SimFeaturesRequest = SimFeaturesUpdateRequest;
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types from Schemas (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user