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:
26
deployments/local/.env
Normal file
26
deployments/local/.env
Normal 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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
26
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
7
services/iam-service/.env
Normal file
7
services/iam-service/.env
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user