Ver código fonte

✨ feat(auth-core): 创建认证核心服务包

- 初始化auth-core包结构和package.json配置
- 实现JWT服务(JWTService),包含token生成、验证、解码功能
- 实现认证服务(AuthService),包含登录、登出、权限验证等功能
- 添加单元测试,覆盖主要认证逻辑和边界情况
- 配置TypeScript编译选项,确保类型安全

✅ test(auth-core): 添加单元测试

- 为AuthService编写测试用例,覆盖登录、权限验证等功能
- 为JWTService编写测试用例,验证token生成、验证和解码逻辑
- 测试用户不存在、密码错误、用户禁用等异常场景
- 验证权限验证方法的正确性
yourname 4 semanas atrás
pai
commit
3d685d90ac

+ 32 - 0
packages/auth-core/package.json

@@ -0,0 +1,32 @@
+{
+  "name": "@d8d/auth-core",
+  "version": "1.0.0",
+  "type": "module",
+  "description": "D8D Authentication Core Services",
+  "main": "src/index.ts",
+  "types": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./src/index.ts",
+      "require": "./src/index.ts",
+      "types": "./src/index.ts"
+    }
+  },
+  "scripts": {
+    "typecheck": "tsc --noEmit",
+    "test": "vitest",
+    "test:unit": "vitest run tests/unit"
+  },
+  "dependencies": {
+    "@d8d/shared-types": "workspace:*",
+    "jsonwebtoken": "^9.0.2",
+    "bcrypt": "^6.0.0"
+  },
+  "devDependencies": {
+    "typescript": "^5.8.3",
+    "vitest": "^3.2.4"
+  },
+  "files": [
+    "src"
+  ]
+}

+ 115 - 0
packages/auth-core/src/auth.service.ts

@@ -0,0 +1,115 @@
+import debug from 'debug';
+import { JWTService, JWTPayload } from './jwt.service.js';
+import { EnableStatus } from '@d8d/shared-types';
+
+const logger = {
+  info: debug('auth-core:auth:info'),
+  error: debug('auth-core:auth:error')
+}
+
+export interface User {
+  id: number;
+  username: string;
+  nickname?: string;
+  isDisabled: EnableStatus;
+  roles?: Array<{ name: string }>;
+  openid?: string;
+}
+
+export interface UserService {
+  getUserByUsername(username: string): Promise<User | null>;
+  verifyPassword(user: User, password: string): Promise<boolean>;
+  createUser(userData: Partial<User> & { username: string; password: string }): Promise<User>;
+}
+
+export class AuthService {
+  private userService: UserService;
+
+  constructor(userService: UserService) {
+    this.userService = userService;
+  }
+
+  async login(username: string, password: string): Promise<{ token: string; user: User }> {
+    try {
+      const user = await this.userService.getUserByUsername(username);
+      if (!user) {
+        throw new Error('用户不存在');
+      }
+
+      // 检查用户是否被禁用
+      if (user.isDisabled === EnableStatus.DISABLED) {
+        throw new Error('用户账户已被禁用');
+      }
+
+      const isPasswordValid = await this.userService.verifyPassword(user, password);
+      if (!isPasswordValid) {
+        throw new Error('密码错误');
+      }
+
+      const payload: JWTPayload = {
+        id: user.id,
+        username: user.username,
+        roles: user.roles?.map(role => role.name) || [],
+        openid: user.openid || undefined
+      };
+
+      const token = JWTService.generateToken(payload);
+      return { token, user };
+    } catch (error) {
+      logger.error('登录错误:', error);
+      throw error;
+    }
+  }
+
+  generateToken(user: User, expiresIn?: string): string {
+    const payload: JWTPayload = {
+      id: user.id,
+      username: user.username,
+      roles: user.roles?.map(role => role.name) || [],
+      openid: user.openid || undefined
+    };
+    return JWTService.generateToken(payload, expiresIn);
+  }
+
+  verifyToken(token: string): JWTPayload {
+    return JWTService.verifyToken(token);
+  }
+
+  async logout(token: string): Promise<void> {
+    try {
+      // 验证token有效性
+      const decoded = this.verifyToken(token);
+      if (!decoded) {
+        throw new Error('无效的token');
+      }
+
+      // 实际项目中这里可以添加token黑名单逻辑
+      // 或者调用Redis等缓存服务使token失效
+
+      return Promise.resolve();
+    } catch (error) {
+      logger.error('登出失败:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * 验证用户权限
+   * @param user 用户信息
+   * @param requiredRole 需要的角色
+   * @returns 是否有权限
+   */
+  hasPermission(user: User, requiredRole: string): boolean {
+    return user.roles?.some(role => role.name === requiredRole) || false;
+  }
+
+  /**
+   * 验证用户是否有任意一个权限
+   * @param user 用户信息
+   * @param requiredRoles 需要的角色列表
+   * @returns 是否有权限
+   */
+  hasAnyPermission(user: User, requiredRoles: string[]): boolean {
+    return requiredRoles.some(role => this.hasPermission(user, role));
+  }
+}

+ 2 - 0
packages/auth-core/src/index.ts

@@ -0,0 +1,2 @@
+export * from './jwt.service.js';
+export * from './auth.service.js';

+ 84 - 0
packages/auth-core/src/jwt.service.ts

@@ -0,0 +1,84 @@
+import jwt, { SignOptions } from 'jsonwebtoken';
+import debug from 'debug';
+
+const logger = {
+  info: debug('auth-core:jwt:info'),
+  error: debug('auth-core:jwt:error')
+};
+
+const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
+const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
+
+export interface JWTPayload {
+  id: number;
+  username: string;
+  roles?: string[];
+  openid?: string;
+}
+
+export class JWTService {
+  /**
+   * 生成 JWT token
+   * @param payload JWT payload 数据
+   * @param expiresIn 过期时间
+   * @returns JWT token
+   */
+  static generateToken(payload: JWTPayload, expiresIn?: string): string {
+    if (!payload.id || !payload.username) {
+      throw new Error('用户ID和用户名不能为空');
+    }
+
+    try {
+      const options: SignOptions = {
+        expiresIn: expiresIn || JWT_EXPIRES_IN as SignOptions['expiresIn']
+      };
+      return jwt.sign(payload, JWT_SECRET, options);
+    } catch (error) {
+      logger.error('生成JWT token失败:', error);
+      throw new Error('生成token失败');
+    }
+  }
+
+  /**
+   * 验证 JWT token
+   * @param token JWT token
+   * @returns 验证后的 payload
+   */
+  static verifyToken(token: string): JWTPayload {
+    try {
+      return jwt.verify(token, JWT_SECRET) as JWTPayload;
+    } catch (error) {
+      logger.error('验证JWT token失败:', error);
+      throw new Error('无效的token');
+    }
+  }
+
+  /**
+   * 解码 JWT token(不验证签名)
+   * @param token JWT token
+   * @returns 解码后的 payload
+   */
+  static decodeToken(token: string): JWTPayload | null {
+    try {
+      return jwt.decode(token) as JWTPayload;
+    } catch (error) {
+      logger.error('解码JWT token失败:', error);
+      return null;
+    }
+  }
+
+  /**
+   * 获取 token 的剩余有效期(秒)
+   * @param token JWT token
+   * @returns 剩余有效期(秒),如果 token 无效则返回 0
+   */
+  static getTokenRemainingTime(token: string): number {
+    try {
+      const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload & { exp: number };
+      const currentTime = Math.floor(Date.now() / 1000);
+      return Math.max(0, decoded.exp - currentTime);
+    } catch (error) {
+      return 0;
+    }
+  }
+}

+ 142 - 0
packages/auth-core/tests/unit/auth.service.test.ts

@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { AuthService, User, UserService } from '../../src/auth.service.js';
+import { EnableStatus } from '@d8d/shared-types';
+
+describe('认证服务', () => {
+  let authService: AuthService;
+  let mockUserService: UserService;
+
+  const mockUser: User = {
+    id: 1,
+    username: 'testuser',
+    nickname: '测试用户',
+    isDisabled: EnableStatus.ENABLED,
+    roles: [{ name: 'user' }]
+  };
+
+  beforeEach(() => {
+    mockUserService = {
+      getUserByUsername: vi.fn(),
+      verifyPassword: vi.fn(),
+      createUser: vi.fn()
+    };
+
+    authService = new AuthService(mockUserService);
+    vi.stubEnv('JWT_SECRET', 'test-secret');
+  });
+
+  describe('登录', () => {
+    it('应该成功登录', async () => {
+      vi.mocked(mockUserService.getUserByUsername).mockResolvedValue(mockUser);
+      vi.mocked(mockUserService.verifyPassword).mockResolvedValue(true);
+
+      const result = await authService.login('testuser', 'password');
+
+      expect(result.token).toBeDefined();
+      expect(result.user).toEqual(mockUser);
+      expect(mockUserService.getUserByUsername).toHaveBeenCalledWith('testuser');
+      expect(mockUserService.verifyPassword).toHaveBeenCalledWith(mockUser, 'password');
+    });
+
+    it('用户不存在时应该抛出错误', async () => {
+      vi.mocked(mockUserService.getUserByUsername).mockResolvedValue(null);
+
+      await expect(authService.login('nonexistent', 'password'))
+        .rejects
+        .toThrow('用户不存在');
+    });
+
+    it('用户被禁用时应该抛出错误', async () => {
+      const disabledUser = { ...mockUser, isDisabled: EnableStatus.DISABLED };
+      vi.mocked(mockUserService.getUserByUsername).mockResolvedValue(disabledUser);
+
+      await expect(authService.login('testuser', 'password'))
+        .rejects
+        .toThrow('用户账户已被禁用');
+    });
+
+    it('密码错误时应该抛出错误', async () => {
+      vi.mocked(mockUserService.getUserByUsername).mockResolvedValue(mockUser);
+      vi.mocked(mockUserService.verifyPassword).mockResolvedValue(false);
+
+      await expect(authService.login('testuser', 'wrongpassword'))
+        .rejects
+        .toThrow('密码错误');
+    });
+  });
+
+  describe('生成token', () => {
+    it('应该成功生成token', () => {
+      const token = authService.generateToken(mockUser);
+
+      expect(token).toBeDefined();
+      expect(typeof token).toBe('string');
+    });
+
+    it('应该使用自定义过期时间生成token', () => {
+      const token = authService.generateToken(mockUser, '2h');
+
+      expect(token).toBeDefined();
+    });
+  });
+
+  describe('验证token', () => {
+    it('应该成功验证token', () => {
+      const token = authService.generateToken(mockUser);
+      const payload = authService.verifyToken(token);
+
+      expect(payload.id).toBe(mockUser.id);
+      expect(payload.username).toBe(mockUser.username);
+    });
+
+    it('无效token应该抛出错误', () => {
+      const invalidToken = 'invalid.token.here';
+
+      expect(() => {
+        authService.verifyToken(invalidToken);
+      }).toThrow('无效的token');
+    });
+  });
+
+  describe('权限验证', () => {
+    it('应该验证用户有权限', () => {
+      const hasPermission = authService.hasPermission(mockUser, 'user');
+
+      expect(hasPermission).toBe(true);
+    });
+
+    it('应该验证用户没有权限', () => {
+      const hasPermission = authService.hasPermission(mockUser, 'admin');
+
+      expect(hasPermission).toBe(false);
+    });
+
+    it('应该验证用户有任意一个权限', () => {
+      const hasAnyPermission = authService.hasAnyPermission(mockUser, ['user', 'admin']);
+
+      expect(hasAnyPermission).toBe(true);
+    });
+
+    it('应该验证用户没有任意一个权限', () => {
+      const hasAnyPermission = authService.hasAnyPermission(mockUser, ['admin', 'superuser']);
+
+      expect(hasAnyPermission).toBe(false);
+    });
+  });
+
+  describe('登出', () => {
+    it('应该成功登出', async () => {
+      const token = authService.generateToken(mockUser);
+
+      await expect(authService.logout(token)).resolves.toBeUndefined();
+    });
+
+    it('无效token登出时应该抛出错误', async () => {
+      const invalidToken = 'invalid.token.here';
+
+      await expect(authService.logout(invalidToken))
+        .rejects
+        .toThrow('无效的token');
+    });
+  });
+});

+ 124 - 0
packages/auth-core/tests/unit/jwt.service.test.ts

@@ -0,0 +1,124 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { JWTService, JWTPayload } from '../../src/jwt.service.js';
+
+describe('JWT服务', () => {
+  beforeEach(() => {
+    vi.stubEnv('JWT_SECRET', 'test-secret');
+    vi.stubEnv('JWT_EXPIRES_IN', '1h');
+  });
+
+  describe('生成token', () => {
+    it('应该成功生成JWT token', () => {
+      const payload: JWTPayload = {
+        id: 1,
+        username: 'testuser',
+        roles: ['admin'],
+        openid: 'test-openid'
+      };
+
+      const token = JWTService.generateToken(payload);
+
+      expect(token).toBeDefined();
+      expect(typeof token).toBe('string');
+      expect(token.split('.')).toHaveLength(3); // JWT token 有3部分
+    });
+
+    it('应该使用自定义过期时间生成token', () => {
+      const payload: JWTPayload = {
+        id: 1,
+        username: 'testuser'
+      };
+
+      const token = JWTService.generateToken(payload, '2h');
+
+      expect(token).toBeDefined();
+    });
+
+    it('缺少用户ID时应该抛出错误', () => {
+      const payload = {
+        username: 'testuser'
+      } as JWTPayload;
+
+      expect(() => {
+        JWTService.generateToken(payload);
+      }).toThrow('用户ID和用户名不能为空');
+    });
+
+    it('缺少用户名时应该抛出错误', () => {
+      const payload = {
+        id: 1
+      } as JWTPayload;
+
+      expect(() => {
+        JWTService.generateToken(payload);
+      }).toThrow('用户ID和用户名不能为空');
+    });
+  });
+
+  describe('验证token', () => {
+    it('应该成功验证有效的token', () => {
+      const payload: JWTPayload = {
+        id: 1,
+        username: 'testuser'
+      };
+
+      const token = JWTService.generateToken(payload);
+      const decoded = JWTService.verifyToken(token);
+
+      expect(decoded.id).toBe(payload.id);
+      expect(decoded.username).toBe(payload.username);
+    });
+
+    it('验证无效token时应该抛出错误', () => {
+      const invalidToken = 'invalid.token.here';
+
+      expect(() => {
+        JWTService.verifyToken(invalidToken);
+      }).toThrow('无效的token');
+    });
+  });
+
+  describe('解码token', () => {
+    it('应该成功解码token', () => {
+      const payload: JWTPayload = {
+        id: 1,
+        username: 'testuser'
+      };
+
+      const token = JWTService.generateToken(payload);
+      const decoded = JWTService.decodeToken(token);
+
+      expect(decoded?.id).toBe(payload.id);
+      expect(decoded?.username).toBe(payload.username);
+    });
+
+    it('解码无效token时应该返回null', () => {
+      const invalidToken = 'invalid.token.here';
+      const decoded = JWTService.decodeToken(invalidToken);
+
+      expect(decoded).toBeNull();
+    });
+  });
+
+  describe('获取token剩余时间', () => {
+    it('应该返回有效的剩余时间', () => {
+      const payload: JWTPayload = {
+        id: 1,
+        username: 'testuser'
+      };
+
+      const token = JWTService.generateToken(payload, '1h');
+      const remainingTime = JWTService.getTokenRemainingTime(token);
+
+      expect(remainingTime).toBeGreaterThan(0);
+      expect(remainingTime).toBeLessThanOrEqual(3600); // 1小时 = 3600秒
+    });
+
+    it('无效token应该返回0', () => {
+      const invalidToken = 'invalid.token.here';
+      const remainingTime = JWTService.getTokenRemainingTime(invalidToken);
+
+      expect(remainingTime).toBe(0);
+    });
+  });
+});

+ 28 - 0
packages/auth-core/tsconfig.json

@@ -0,0 +1,28 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": false,
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noImplicitOverride": true,
+    "outDir": "./dist",
+    "rootDir": ".",
+    "declaration": true,
+    "skipLibCheck": true
+  },
+  "include": [
+    "src/**/*",
+    "tests/**/*"
+  ],
+  "exclude": [
+    "node_modules",
+    "dist"
+  ]
+}