feat: Cải thiện dịch vụ mã hóa AES-256-GCM và tích hợp khóa mã hóa vào cấu hình môi trường để bảo vệ dữ liệu nhạy cảm.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 10:48:08 +07:00
parent 9aed3da8eb
commit 7154c37a31
12 changed files with 113 additions and 87 deletions

26
deployments/local/.env Normal file
View File

@@ -0,0 +1,26 @@
# SHARED CONFIG
NODE_ENV=development
LOG_LEVEL=debug
API_VERSION=v1
CORS_ORIGIN=http://localhost:3000,http://localhost:3001,http://localhost,http://admin.localhost
# AUTH
JWT_SECRET='super-secret-jwt-key-for-local-dev-must-be-min-32-chars'
JWT_REFRESH_SECRET='super-secret-refresh-key-for-local-dev-must-be-min-32-chars'
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
JWT_ID_SECRET='super-secret-id-key-for-local-dev-must-be-min-32-chars'
JWT_ID_EXPIRES_IN=1h
# ENCRYPTION
ENCRYPTION_KEY='460d261122522a6da8df4b9116a55d97432102a524cf055c04118265f0e51693'
# INFRA
REDIS_HOST=redis
REDIS_PORT=6379
DATABASE_URL='postgresql://neondb_owner:npg_Ssfy6HKO0cXI@ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech/iam-service?sslmode=require&channel_binding=require'
# OBSERVABILITY
TRACING_ENABLED=false
JAEGER_ENDPOINT=http://jaeger:14268/api/traces
METRICS_ENABLED=true

View File

@@ -102,6 +102,7 @@ services:
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-15m}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
- JWT_REFRESH_EXPIRES_IN=${JWT_REFRESH_EXPIRES_IN:-7d}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- CORS_ORIGIN=${CORS_ORIGIN}
- TRACING_ENABLED=${TRACING_ENABLED:-false}
- JAEGER_ENDPOINT=${JAEGER_ENDPOINT}

View File

@@ -21,6 +21,15 @@ JWT_REFRESH_SECRET=your-super-secret-refresh-key-min-32-characters-change-me
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# ID Token (OIDC)
JWT_ID_SECRET=your-super-secret-id-key-min-32-characters-change-me
JWT_ID_EXPIRES_IN=1h
# Data Encryption (AES-256-GCM)
# Required for encrypting sensitive data at rest (MFA secrets, etc.)
# Generate: openssl rand -hex 32
ENCRYPTION_KEY=your-32-byte-hex-encryption-key-must-be-64-chars
# =============================================================================
# SHARED INFRASTRUCTURE
# =============================================================================

26
pnpm-lock.yaml generated
View File

@@ -549,7 +549,7 @@ importers:
specifier: ^17.2.3
version: 17.2.3
express:
specifier: ^4.18.2
specifier: ^4.22.1
version: 4.22.1
express-rate-limit:
specifier: ^7.1.5
@@ -1177,16 +1177,16 @@ packages:
kuler: 2.0.0
dev: false
/@emnapi/core@1.7.1:
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
/@emnapi/core@1.8.0:
resolution: {integrity: sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg==}
requiresBuild: true
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
optional: true
/@emnapi/runtime@1.7.1:
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
/@emnapi/runtime@1.8.0:
resolution: {integrity: sha512-Z82FDl1ByxqPEPrAYYeTQVlx2FSHPe1qwX465c+96IRS3fTdSYRoJcRxg3g2fEG5I69z1dSEWQlNRRr0/677mg==}
requiresBuild: true
dependencies:
tslib: 2.8.1
@@ -1947,8 +1947,8 @@ packages:
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
requiresBuild: true
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/core': 1.8.0
'@emnapi/runtime': 1.8.0
'@tybys/wasm-util': 0.10.1
optional: true
@@ -6635,7 +6635,7 @@ packages:
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.14.0
qs: 6.14.1
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
@@ -8065,7 +8065,7 @@ packages:
parseurl: 1.3.3
path-to-regexp: 0.1.12
proxy-addr: 2.0.7
qs: 6.14.0
qs: 6.14.1
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.19.2
@@ -10934,6 +10934,14 @@ packages:
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.1.0
dev: true
/qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
dependencies:
side-channel: 1.1.0
dev: false
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}

View File

@@ -190,7 +190,7 @@ export abstract class BaseRepository<T, CreateInput, UpdateInput> {
try {
logger.debug(`Starting ${this.modelName} transaction / Bắt đầu transaction ${this.modelName}`);
const result = await this.prisma.$transaction(async (tx) => {
const result = await this.prisma.$transaction(async (tx: any) => {
return await callback(tx);
});

View File

@@ -0,0 +1,7 @@
DATABASE_URL='postgresql://neondb_owner:npg_Ssfy6HKO0cXI@ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech/iam-service?sslmode=require&channel_binding=require'
ENCRYPTION_KEY='460d261122522a6da8df4b9116a55d97432102a524cf055c04118265f0e51693'
JWT_SECRET='super-secret-jwt-key-for-local-dev-must-be-min-32-chars'
JWT_REFRESH_SECRET='super-secret-refresh-key-for-local-dev-must-be-min-32-chars'
JWT_ID_SECRET='super-secret-id-key-for-local-dev-must-be-min-32-chars'
NODE_ENV='development'
PORT=5001

View File

@@ -3,7 +3,9 @@
# EN: Base stage with security updates
# VI: Base stage với security updates
FROM node:20-alpine AS base
# EN: Base stage with security updates (Node 22)
# VI: Base stage với security updates (Node 22)
FROM node:22-alpine AS base
# EN: Install security updates and required packages
# VI: Cài đặt security updates và packages cần thiết
@@ -33,62 +35,17 @@ USER root
RUN corepack enable pnpm
USER node
# EN: Copy package files
# VI: Copy package files
COPY --chown=node:node services/iam-service/package.json services/iam-service/pnpm-lock.yaml* ./
# EN: Copy source code (files must be owned by node user for pnpm to write)
# VI: Copy source code (files phải thuộc về node user để pnpm có thể ghi)
COPY --chown=node:node . .
# EN: Copy workspace metadata so pnpm can resolve workspace:* dependencies
# VI: Copy workspace metadata để pnpm có thể resolve các dependency workspace:*
# Note: copying only workspace manifest and packages directory (package.json files)
# keeps the build context smallish while enabling workspace resolution.
COPY --chown=node:node ../../pnpm-workspace.yaml ./
COPY --chown=node:node ../../packages ./packages
# EN: Build iam-service and its dependencies only
# VI: Chỉ build iam-service và các dependencies của nó
RUN pnpm install --frozen-lockfile=false && pnpm --filter @goodgo/iam-service... build
# EN: Install dependencies only (no dev dependencies for smaller image)
# VI: Install dependencies only (không có dev dependencies để image nhỏ hơn)
# Use shamefully-hoist to create a flat node_modules layout so tooling like npx/prisma can resolve modules
RUN pnpm install --prod=false --shamefully-hoist && pnpm store prune
# EN: Builder stage - compile TypeScript and generate Prisma client
# VI: Builder stage - compile TypeScript và generate Prisma client
FROM base AS builder
USER root
RUN chown node:node /app
USER node
# EN: Enable corepack
# VI: Enable corepack
USER root
RUN corepack enable pnpm
USER node
# EN: Copy dependencies from deps stage
# VI: Copy dependencies từ deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# EN: Copy source code
# VI: Copy source code
COPY --chown=node:node services/iam-service/ .
# EN: Build application
# VI: Build application
# Copy generated Prisma client from host (pre-generated) into builder to avoid running build scripts inside the image
# This uses a wildcard to match pnpm's generated folder name in the host node_modules/.pnpm directory.
COPY --chown=node:node services/iam-service/_prisma_client/@prisma /tmp/_prisma/@prisma
COPY --chown=node:node services/iam-service/_prisma_client/@prisma/client /tmp/_prisma/@prisma/client
# Move vendored prisma client into node_modules, replacing any existing entries
USER root
RUN rm -rf ./node_modules/@prisma || true && \
mkdir -p ./node_modules && \
mv /tmp/_prisma/@prisma ./node_modules/@prisma && \
chown -R node:node ./node_modules/@prisma
USER node
RUN pnpm build
# EN: Prune dev dependencies after build
# VI: Prune dev dependencies sau khi build
USER root
RUN pnpm prune --prod
USER node
# EN: Production stage - minimal runtime image
# VI: Production stage - minimal runtime image
@@ -117,10 +74,10 @@ USER microservice
# EN: Copy built application from builder stage
# VI: Copy built application từ builder stage
COPY --from=builder --chown=microservice:nodejs /app/dist ./dist
COPY --from=builder --chown=microservice:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=microservice:nodejs /app/package.json ./
COPY --from=builder --chown=microservice:nodejs /app/prisma ./prisma
COPY --from=deps --chown=microservice:nodejs /app/services/iam-service/dist ./dist
COPY --from=deps --chown=microservice:nodejs /app/node_modules ./node_modules
COPY --from=deps --chown=microservice:nodejs /app/services/iam-service/package.json ./
COPY --from=deps --chown=microservice:nodejs /app/services/iam-service/prisma ./prisma
# EN: Add health check
# VI: Thêm health check

View File

@@ -35,7 +35,7 @@
"cors": "^2.8.5",
"dompurify": "^3.3.1",
"dotenv": "^17.2.3",
"express": "^4.18.2",
"express": "^4.22.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"ioredis": "^5.3.2",

View File

@@ -20,7 +20,7 @@ model User {
isActive Boolean @default(true)
emailVerified Boolean @default(false)
mfaEnabled Boolean @default(false)
mfaSecret String?
mfaSecret String? // Encrypted TOTP secret
mfaBackupCodes String? @db.Text // Encrypted JSON array of backup codes
lastLoginAt DateTime?
loginCount Int @default(0)
@@ -206,8 +206,8 @@ model SocialAccount {
email String?
name String?
avatar String?
accessToken String? @db.Text
refreshToken String? @db.Text
accessToken String? @db.Text // Encrypted access token
refreshToken String? @db.Text // Encrypted refresh token
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -235,7 +235,7 @@ model MFADevice {
userId String
type MFAType // TOTP, WEBAUTHN
name String // Device name
secret String? // For TOTP
secret String? // Encrypted TOTP secret
credentialId String? // For WebAuthn
publicKey String? @db.Text // For WebAuthn
counter Int? // For WebAuthn

View File

@@ -24,12 +24,18 @@ export class EncryptionService {
throw new Error('ENCRYPTION_KEY must be configured and not use default value');
}
// If key is shorter than required, hash it to create proper length
// If key is 64 chars hex (32 bytes), parse it
if (key.length === 64 && /^[0-9a-fA-F]+$/.test(key)) {
return Buffer.from(key, 'hex');
}
// If key is shorter than required, hash it
if (key.length < this.keyLength) {
return crypto.scryptSync(key, 'salt', this.keyLength);
}
// If key is longer, truncate it
// If key is longer (and not hex 64), truncate it
if (key.length > this.keyLength) {
return Buffer.from(key.slice(0, this.keyLength));
}
@@ -45,10 +51,8 @@ export class EncryptionService {
try {
const key = this.getKey();
const iv = crypto.randomBytes(this.ivLength);
const cipher = (crypto as any).createCipherGCM(this.algorithm, key);
const cipher = crypto.createCipheriv(this.algorithm, key, iv) as crypto.CipherGCM;
cipher.setAAD(Buffer.from('iam-service')); // Additional authenticated data
cipher.setIV(iv); // Set the initialization vector
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
@@ -83,10 +87,9 @@ export class EncryptionService {
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = (crypto as any).createDecipherGCM(this.algorithm, key);
const decipher = crypto.createDecipheriv(this.algorithm, key, iv) as crypto.DecipherGCM;
decipher.setAAD(Buffer.from('iam-service')); // Same AAD as encryption
decipher.setAuthTag(authTag);
decipher.setIV(iv); // Set the initialization vector
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
@@ -106,9 +109,9 @@ export class EncryptionService {
isEncrypted(data: string): boolean {
const parts = data.split(':');
return parts.length === 3 &&
parts[0].length === this.ivLength * 2 && // IV in hex
parts[1].length === this.tagLength * 2 && // Auth tag in hex
parts[2].length > 0; // Encrypted data
parts[0].length === this.ivLength * 2 && // IV in hex
parts[1].length === this.tagLength * 2 && // Auth tag in hex
parts[2].length > 0; // Encrypted data
}
/**

View File

@@ -66,7 +66,7 @@ export class SocialAuthService {
redirectUri?: string
): Promise<SocialAuthResponse> {
const providerInstance = this.getProvider(provider);
// Use circuit breaker for external API calls
const circuitBreaker = createCircuitBreaker(
async () => {
@@ -134,6 +134,8 @@ export class SocialAuthService {
provider: Provider,
profile: SocialProfile
): Promise<SocialAccountResponse> {
// Note: If storing accessToken/refreshToken in the future,
// ensure they are encrypted using encryptionService before saving.
const existing = await this.prisma.socialAccount.findUnique({
where: {
provider_providerId: {

View File

@@ -5,12 +5,25 @@
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"types": ["jest", "node"],
"types": [
"jest",
"node"
],
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"tests",
"src/__tests__",
"**/*.test.ts"
]
}