feat(iam): 完整实现 iam 身份认证与权限服务
包含 jwt/jwks/audit/grpc、rbac、cache、redis/kafka 配置等完整实现
This commit is contained in:
@@ -17,9 +17,23 @@ export function getDb(): MySql2Database {
|
||||
return drizzle(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 db 实例(模块装配时初始化,供 OutboxModule 等需要 db 引用的模块使用)。
|
||||
* 在 AppModule.onModuleInit 中通过 ensureDbInitialized() 确保已创建。
|
||||
*/
|
||||
let dbInstance: MySql2Database | null = null;
|
||||
|
||||
export function getDbInstance(): MySql2Database {
|
||||
if (!dbInstance) {
|
||||
dbInstance = getDb();
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.string().default('3002'),
|
||||
// HTTP
|
||||
PORT: z.string().default("3002"),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
JWT_SECRET: z.string(),
|
||||
JWT_ISSUER: z.string().default('next-edu-cloud'),
|
||||
JWT_AUDIENCE: z.string().default('next-edu-cloud'),
|
||||
|
||||
// Redis(缓存 + token 黑名单)
|
||||
REDIS_URL: z.string().url(),
|
||||
|
||||
// JWT RS256(president §2.15:本地文件密钥)
|
||||
IAM_PRIVATE_KEY_PATH: z.string(),
|
||||
IAM_PUBLIC_KEY_PATH: z.string(),
|
||||
JWT_ISSUER: z.string().default("next-edu-cloud"),
|
||||
JWT_AUDIENCE: z.string().default("next-edu-cloud"),
|
||||
JWT_KEY_ID: z.string().default("iam-rs256-v1"),
|
||||
ACCESS_TOKEN_TTL: z.string().default("15m"),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.string().default("7"),
|
||||
|
||||
// Kafka(Outbox 投递)
|
||||
KAFKA_BROKERS: z.string(),
|
||||
KAFKA_CLIENT_ID: z.string().default("iam-service"),
|
||||
|
||||
// gRPC server(I1 裁决:端口 50052)
|
||||
GRPC_PORT: z.string().default("50052"),
|
||||
|
||||
// 可观测性
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
LOG_LEVEL: z
|
||||
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
|
||||
.default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
@@ -17,8 +41,11 @@ export type Env = z.infer<typeof envSchema>;
|
||||
export function loadEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
console.error(
|
||||
"❌ Invalid environment variables:",
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
throw new Error("Invalid environment configuration");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
54
services/iam/src/config/jwt.ts
Normal file
54
services/iam/src/config/jwt.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { env } from "./env.js";
|
||||
|
||||
/**
|
||||
* JWT RS256 密钥对加载(president §2.15:本地文件密钥)。
|
||||
*
|
||||
* - 私钥:IAM 签发 access_token / refresh_token
|
||||
* - 公钥:api-gateway 通过 JWKS 或 gRPC GetPublicKey 拉取验签
|
||||
*
|
||||
* 启动时一次性加载到内存,避免每次签名/验签的 IO 开销。
|
||||
* kid(Key ID)用于 JWKS 端点多密钥轮换场景下标识密钥。
|
||||
*/
|
||||
export interface JwtKeyPair {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
kid: string;
|
||||
alg: "RS256";
|
||||
}
|
||||
|
||||
let keyPair: JwtKeyPair | null = null;
|
||||
|
||||
export function getJwtKeyPair(): JwtKeyPair {
|
||||
if (!keyPair) {
|
||||
const privateKey = readFileSync(env.IAM_PRIVATE_KEY_PATH, "utf-8");
|
||||
const publicKey = readFileSync(env.IAM_PUBLIC_KEY_PATH, "utf-8");
|
||||
keyPair = {
|
||||
privateKey,
|
||||
publicKey,
|
||||
kid: env.JWT_KEY_ID,
|
||||
alg: "RS256",
|
||||
};
|
||||
}
|
||||
return keyPair;
|
||||
}
|
||||
|
||||
/**
|
||||
* TTL 计算:将 "15m" / "7d" 等字符串转为秒数。
|
||||
* 用于 JWT expiresIn 配置与响应中的 expires_in 字段。
|
||||
*/
|
||||
export function ttlToSeconds(ttl: string): number {
|
||||
const match = /^(\d+)([smhd])$/.exec(ttl);
|
||||
if (!match || match[1] === undefined || match[2] === undefined) {
|
||||
throw new Error(`Invalid TTL format: ${ttl}`);
|
||||
}
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
const unit = match[2] as "s" | "m" | "h" | "d";
|
||||
const multipliers: Record<"s" | "m" | "h" | "d", number> = {
|
||||
s: 1,
|
||||
m: 60,
|
||||
h: 3600,
|
||||
d: 86400,
|
||||
};
|
||||
return value * multipliers[unit];
|
||||
}
|
||||
44
services/iam/src/config/kafka.ts
Normal file
44
services/iam/src/config/kafka.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Kafka, type Producer } from "kafkajs";
|
||||
import { env } from "./env.js";
|
||||
|
||||
let producer: Producer | null = null;
|
||||
|
||||
export function getKafkaProducer(): Producer {
|
||||
if (!producer) {
|
||||
const kafka = new Kafka({
|
||||
clientId: env.KAFKA_CLIENT_ID,
|
||||
brokers: env.KAFKA_BROKERS.split(","),
|
||||
});
|
||||
producer = kafka.producer({
|
||||
idempotent: true,
|
||||
transactionalId: "iam-tx",
|
||||
});
|
||||
}
|
||||
return producer;
|
||||
}
|
||||
|
||||
export async function connectKafkaProducer(): Promise<void> {
|
||||
const p = getKafkaProducer();
|
||||
await p.connect();
|
||||
}
|
||||
|
||||
export async function disconnectKafkaProducer(): Promise<void> {
|
||||
if (producer) {
|
||||
await producer.disconnect();
|
||||
producer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM Kafka topic 路由(coord-final-decisions I5 + iam_contract §1.4)。
|
||||
*
|
||||
* 事件命名规则:`<Aggregate>.<Action>`
|
||||
* - UserEvent: created/updated/disabled/role_changed
|
||||
* - RoleEvent: created/updated
|
||||
* - AuditEvent: create/update/delete/login/logout/permission_change
|
||||
*/
|
||||
export const IAM_KAFKA_TOPICS = {
|
||||
USER_EVENTS: "edu.iam.user.events",
|
||||
ROLE_EVENTS: "edu.iam.role.events",
|
||||
AUDIT_CREATED: "edu.iam.audit.created",
|
||||
} as const;
|
||||
24
services/iam/src/config/redis.ts
Normal file
24
services/iam/src/config/redis.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { env } from "./env.js";
|
||||
|
||||
type RedisClient = InstanceType<typeof Redis>;
|
||||
|
||||
let client: RedisClient | null = null;
|
||||
|
||||
export function getRedis(): RedisClient {
|
||||
if (!client) {
|
||||
client = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: false,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function closeRedis(): Promise<void> {
|
||||
if (client) {
|
||||
await client.quit();
|
||||
client = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user