From 7154c37a31eb99d3231cabd8f939221f92d4ca3f Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 4 Jan 2026 10:48:08 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20C=E1=BA=A3i=20thi=E1=BB=87n=20d?= =?UTF-8?q?=E1=BB=8Bch=20v=E1=BB=A5=20m=C3=A3=20h=C3=B3a=20AES-256-GCM=20v?= =?UTF-8?q?=C3=A0=20t=C3=ADch=20h=E1=BB=A3p=20kh=C3=B3a=20m=C3=A3=20h?= =?UTF-8?q?=C3=B3a=20v=C3=A0o=20c=E1=BA=A5u=20h=C3=ACnh=20m=C3=B4i=20tr?= =?UTF-8?q?=C6=B0=E1=BB=9Dng=20=C4=91=E1=BB=83=20b=E1=BA=A3o=20v=E1=BB=87?= =?UTF-8?q?=20d=E1=BB=AF=20li=E1=BB=87u=20nh=E1=BA=A1y=20c=E1=BA=A3m.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deployments/local/.env | 26 +++++++ deployments/local/docker-compose.yml | 1 + deployments/local/env.local.example | 9 +++ pnpm-lock.yaml | 26 ++++--- .../src/modules/common/repository.ts | 2 +- services/iam-service/.env | 7 ++ services/iam-service/Dockerfile | 69 ++++--------------- services/iam-service/package.json | 2 +- services/iam-service/prisma/schema.prisma | 8 +-- .../src/core/security/encryption.service.ts | 23 ++++--- .../src/modules/social/social.service.ts | 4 +- services/iam-service/tsconfig.json | 23 +++++-- 12 files changed, 113 insertions(+), 87 deletions(-) create mode 100644 deployments/local/.env create mode 100644 services/iam-service/.env diff --git a/deployments/local/.env b/deployments/local/.env new file mode 100644 index 00000000..dda3570e --- /dev/null +++ b/deployments/local/.env @@ -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 diff --git a/deployments/local/docker-compose.yml b/deployments/local/docker-compose.yml index d8d53260..a27a8cd5 100644 --- a/deployments/local/docker-compose.yml +++ b/deployments/local/docker-compose.yml @@ -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} diff --git a/deployments/local/env.local.example b/deployments/local/env.local.example index 1059430a..9cc115cc 100644 --- a/deployments/local/env.local.example +++ b/deployments/local/env.local.example @@ -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 # ============================================================================= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13727bf8..11acc113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==} diff --git a/services/_template/src/modules/common/repository.ts b/services/_template/src/modules/common/repository.ts index 7c1ccbf9..04d65fdd 100644 --- a/services/_template/src/modules/common/repository.ts +++ b/services/_template/src/modules/common/repository.ts @@ -190,7 +190,7 @@ export abstract class BaseRepository { 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); }); diff --git a/services/iam-service/.env b/services/iam-service/.env new file mode 100644 index 00000000..c0145f91 --- /dev/null +++ b/services/iam-service/.env @@ -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 diff --git a/services/iam-service/Dockerfile b/services/iam-service/Dockerfile index f7f09a15..a83cba06 100644 --- a/services/iam-service/Dockerfile +++ b/services/iam-service/Dockerfile @@ -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 diff --git a/services/iam-service/package.json b/services/iam-service/package.json index 9d01a4f2..9fea53e3 100644 --- a/services/iam-service/package.json +++ b/services/iam-service/package.json @@ -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", diff --git a/services/iam-service/prisma/schema.prisma b/services/iam-service/prisma/schema.prisma index 5ad016e8..1e792714 100644 --- a/services/iam-service/prisma/schema.prisma +++ b/services/iam-service/prisma/schema.prisma @@ -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 diff --git a/services/iam-service/src/core/security/encryption.service.ts b/services/iam-service/src/core/security/encryption.service.ts index 523bfc12..13945846 100644 --- a/services/iam-service/src/core/security/encryption.service.ts +++ b/services/iam-service/src/core/security/encryption.service.ts @@ -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 } /** diff --git a/services/iam-service/src/modules/social/social.service.ts b/services/iam-service/src/modules/social/social.service.ts index 49f1dac6..aaf83613 100644 --- a/services/iam-service/src/modules/social/social.service.ts +++ b/services/iam-service/src/modules/social/social.service.ts @@ -66,7 +66,7 @@ export class SocialAuthService { redirectUri?: string ): Promise { 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 { + // 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: { diff --git a/services/iam-service/tsconfig.json b/services/iam-service/tsconfig.json index 5e7a57e6..8f6d4be4 100644 --- a/services/iam-service/tsconfig.json +++ b/services/iam-service/tsconfig.json @@ -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" + ] +} \ No newline at end of file