feat(server): 添加可插拔认证系统 | add pluggable authentication system (#386)
* feat(server): 添加可插拔认证系统 | add pluggable authentication system - 新增 JWT 认证提供者 (createJwtAuthProvider) - 新增 Session 认证提供者 (createSessionAuthProvider) - 新增服务器认证 mixin (withAuth) - 新增房间认证 mixin (withRoomAuth) - 新增认证装饰器 (@requireAuth, @requireRole) - 新增测试工具 (MockAuthProvider) - 新增中英文文档 - 导出路径: @esengine/server/auth, @esengine/server/auth/testing * fix(server): 使用加密安全的随机数生成 session ID | use crypto-secure random for session ID
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { MockAuthProvider, createMockAuthProvider, type MockUser } from '../testing/MockAuthProvider';
|
||||
|
||||
describe('MockAuthProvider', () => {
|
||||
const testUsers: MockUser[] = [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
{ id: '3', name: 'Charlie', roles: ['guest'] }
|
||||
];
|
||||
|
||||
let provider: MockAuthProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = createMockAuthProvider({
|
||||
users: testUsers
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic properties', () => {
|
||||
it('should have name "mock"', () => {
|
||||
expect(provider.name).toBe('mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify', () => {
|
||||
it('should verify existing user by id (token)', async () => {
|
||||
const result = await provider.verify('1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('1');
|
||||
expect(result.user?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return user roles', async () => {
|
||||
const result = await provider.verify('2');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.roles).toEqual(['admin', 'player']);
|
||||
});
|
||||
|
||||
it('should fail for unknown user', async () => {
|
||||
const result = await provider.verify('unknown');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should fail for empty token', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should return expiresAt', async () => {
|
||||
const result = await provider.verify('1');
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('with defaultUser', () => {
|
||||
it('should return default user for empty token', async () => {
|
||||
const providerWithDefault = createMockAuthProvider({
|
||||
defaultUser: { id: 'default', name: 'Guest' }
|
||||
});
|
||||
|
||||
const result = await providerWithDefault.verify('');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with autoCreate', () => {
|
||||
it('should auto create user for unknown token', async () => {
|
||||
const autoProvider = createMockAuthProvider({
|
||||
autoCreate: true
|
||||
});
|
||||
|
||||
const result = await autoProvider.verify('new-user-123');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('new-user-123');
|
||||
expect(result.user?.name).toBe('User_new-user-123');
|
||||
expect(result.user?.roles).toEqual(['guest']);
|
||||
});
|
||||
|
||||
it('should persist auto-created users', async () => {
|
||||
const autoProvider = createMockAuthProvider({
|
||||
autoCreate: true
|
||||
});
|
||||
|
||||
await autoProvider.verify('auto-1');
|
||||
const user = autoProvider.getUser('auto-1');
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toBe('auto-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with validateToken', () => {
|
||||
it('should validate token format', async () => {
|
||||
const validatingProvider = createMockAuthProvider({
|
||||
users: testUsers,
|
||||
validateToken: (token) => token.length >= 1 && !token.includes('invalid')
|
||||
});
|
||||
|
||||
const validResult = await validatingProvider.verify('1');
|
||||
expect(validResult.success).toBe(true);
|
||||
|
||||
const invalidResult = await validatingProvider.verify('invalid-token');
|
||||
expect(invalidResult.success).toBe(false);
|
||||
expect(invalidResult.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with delay', () => {
|
||||
it('should add artificial delay', async () => {
|
||||
const delayProvider = createMockAuthProvider({
|
||||
users: testUsers,
|
||||
delay: 50
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
await delayProvider.verify('1');
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh token (returns same result as verify)', async () => {
|
||||
const result = await provider.refresh('1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke token', async () => {
|
||||
const result1 = await provider.verify('1');
|
||||
expect(result1.success).toBe(true);
|
||||
|
||||
const revoked = await provider.revoke('1');
|
||||
expect(revoked).toBe(true);
|
||||
|
||||
const result2 = await provider.verify('1');
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user management', () => {
|
||||
it('should add user', () => {
|
||||
provider.addUser({ id: '4', name: 'Dave', roles: ['tester'] });
|
||||
const user = provider.getUser('4');
|
||||
expect(user?.name).toBe('Dave');
|
||||
});
|
||||
|
||||
it('should remove user', () => {
|
||||
const removed = provider.removeUser('1');
|
||||
expect(removed).toBe(true);
|
||||
|
||||
const user = provider.getUser('1');
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false when removing non-existent user', () => {
|
||||
const removed = provider.removeUser('non-existent');
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all users', () => {
|
||||
const users = provider.getUsers();
|
||||
expect(users).toHaveLength(3);
|
||||
expect(users.map(u => u.id)).toContain('1');
|
||||
expect(users.map(u => u.id)).toContain('2');
|
||||
expect(users.map(u => u.id)).toContain('3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should reset to initial state', async () => {
|
||||
provider.addUser({ id: '4', name: 'Dave' });
|
||||
await provider.revoke('1');
|
||||
|
||||
provider.clear();
|
||||
|
||||
const users = provider.getUsers();
|
||||
expect(users).toHaveLength(3);
|
||||
|
||||
const result = await provider.verify('1');
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateToken', () => {
|
||||
it('should return user id as token', () => {
|
||||
const token = provider.generateToken('user-123');
|
||||
expect(token).toBe('user-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMockAuthProvider', () => {
|
||||
it('should create provider with empty config', () => {
|
||||
const provider = createMockAuthProvider();
|
||||
expect(provider.name).toBe('mock');
|
||||
});
|
||||
|
||||
it('should create provider with custom users', () => {
|
||||
const provider = createMockAuthProvider({
|
||||
users: [{ id: 'test', name: 'Test User' }]
|
||||
});
|
||||
|
||||
const user = provider.getUser('test');
|
||||
expect(user?.name).toBe('Test User');
|
||||
});
|
||||
});
|
||||
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { AuthContext, createGuestContext, createAuthContext, defaultUserExtractor } from '../context';
|
||||
import type { AuthResult } from '../types';
|
||||
|
||||
describe('AuthContext', () => {
|
||||
let context: AuthContext<{ id: string; name: string; roles: string[] }>;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new AuthContext();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should not be authenticated initially', () => {
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should have null user initially', () => {
|
||||
expect(context.user).toBeNull();
|
||||
});
|
||||
|
||||
it('should have null userId initially', () => {
|
||||
expect(context.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('should have empty roles initially', () => {
|
||||
expect(context.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have null authenticatedAt initially', () => {
|
||||
expect(context.authenticatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should have null expiresAt initially', () => {
|
||||
expect(context.expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAuthenticated', () => {
|
||||
it('should set authenticated state on success', () => {
|
||||
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||
};
|
||||
|
||||
context.setAuthenticated(result);
|
||||
|
||||
expect(context.isAuthenticated).toBe(true);
|
||||
expect(context.user).toEqual({ id: '123', name: 'Alice', roles: ['player'] });
|
||||
expect(context.userId).toBe('123');
|
||||
expect(context.roles).toEqual(['player']);
|
||||
expect(context.authenticatedAt).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('should set expiresAt when provided', () => {
|
||||
const expiresAt = Date.now() + 3600000;
|
||||
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt
|
||||
};
|
||||
|
||||
context.setAuthenticated(result);
|
||||
|
||||
expect(context.expiresAt).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('should clear state on failed result', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||
});
|
||||
|
||||
context.setAuthenticated({
|
||||
success: false,
|
||||
error: 'Token expired'
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
expect(context.user).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear state when success but no user', () => {
|
||||
context.setAuthenticated({
|
||||
success: true
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated with expiry', () => {
|
||||
it('should return false when token is expired', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt: Date.now() - 1000
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when token is not expired', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: [] },
|
||||
expiresAt: Date.now() + 3600000
|
||||
});
|
||||
|
||||
expect(context.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role checking', () => {
|
||||
beforeEach(() => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player', 'premium'] }
|
||||
});
|
||||
});
|
||||
|
||||
it('hasRole should return true for existing role', () => {
|
||||
expect(context.hasRole('player')).toBe(true);
|
||||
expect(context.hasRole('premium')).toBe(true);
|
||||
});
|
||||
|
||||
it('hasRole should return false for non-existing role', () => {
|
||||
expect(context.hasRole('admin')).toBe(false);
|
||||
});
|
||||
|
||||
it('hasAnyRole should return true if any role matches', () => {
|
||||
expect(context.hasAnyRole(['admin', 'player'])).toBe(true);
|
||||
expect(context.hasAnyRole(['guest', 'premium'])).toBe(true);
|
||||
});
|
||||
|
||||
it('hasAnyRole should return false if no role matches', () => {
|
||||
expect(context.hasAnyRole(['admin', 'moderator'])).toBe(false);
|
||||
});
|
||||
|
||||
it('hasAllRoles should return true if all roles match', () => {
|
||||
expect(context.hasAllRoles(['player', 'premium'])).toBe(true);
|
||||
});
|
||||
|
||||
it('hasAllRoles should return false if any role is missing', () => {
|
||||
expect(context.hasAllRoles(['player', 'admin'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should reset all state', () => {
|
||||
context.setAuthenticated({
|
||||
success: true,
|
||||
user: { id: '123', name: 'Alice', roles: ['player'] },
|
||||
expiresAt: Date.now() + 3600000
|
||||
});
|
||||
|
||||
context.clear();
|
||||
|
||||
expect(context.isAuthenticated).toBe(false);
|
||||
expect(context.user).toBeNull();
|
||||
expect(context.userId).toBeNull();
|
||||
expect(context.roles).toEqual([]);
|
||||
expect(context.authenticatedAt).toBeNull();
|
||||
expect(context.expiresAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultUserExtractor', () => {
|
||||
describe('getId', () => {
|
||||
it('should extract id from user object', () => {
|
||||
expect(defaultUserExtractor.getId({ id: '123' })).toBe('123');
|
||||
});
|
||||
|
||||
it('should extract numeric id as string', () => {
|
||||
expect(defaultUserExtractor.getId({ id: 456 })).toBe('456');
|
||||
});
|
||||
|
||||
it('should extract userId', () => {
|
||||
expect(defaultUserExtractor.getId({ userId: 'abc' })).toBe('abc');
|
||||
});
|
||||
|
||||
it('should extract sub (JWT standard)', () => {
|
||||
expect(defaultUserExtractor.getId({ sub: 'jwt-sub' })).toBe('jwt-sub');
|
||||
});
|
||||
|
||||
it('should return empty string for invalid user', () => {
|
||||
expect(defaultUserExtractor.getId(null)).toBe('');
|
||||
expect(defaultUserExtractor.getId(undefined)).toBe('');
|
||||
expect(defaultUserExtractor.getId({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoles', () => {
|
||||
it('should extract roles array', () => {
|
||||
expect(defaultUserExtractor.getRoles({ roles: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should extract single role', () => {
|
||||
expect(defaultUserExtractor.getRoles({ role: 'admin' })).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('should filter non-string roles', () => {
|
||||
expect(defaultUserExtractor.getRoles({ roles: ['a', 123, 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid user', () => {
|
||||
expect(defaultUserExtractor.getRoles(null)).toEqual([]);
|
||||
expect(defaultUserExtractor.getRoles({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGuestContext', () => {
|
||||
it('should create unauthenticated context', () => {
|
||||
const guest = createGuestContext();
|
||||
expect(guest.isAuthenticated).toBe(false);
|
||||
expect(guest.user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAuthContext', () => {
|
||||
it('should create authenticated context from result', () => {
|
||||
const result: AuthResult<{ id: string }> = {
|
||||
success: true,
|
||||
user: { id: '123' }
|
||||
};
|
||||
|
||||
const ctx = createAuthContext(result);
|
||||
expect(ctx.isAuthenticated).toBe(true);
|
||||
expect(ctx.userId).toBe('123');
|
||||
});
|
||||
|
||||
it('should create unauthenticated context from failed result', () => {
|
||||
const result: AuthResult<{ id: string }> = {
|
||||
success: false,
|
||||
error: 'Failed'
|
||||
};
|
||||
|
||||
const ctx = createAuthContext(result);
|
||||
expect(ctx.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { requireAuth, AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from '../decorators/requireAuth';
|
||||
import { requireRole } from '../decorators/requireRole';
|
||||
|
||||
describe('requireAuth decorator', () => {
|
||||
class TestClass {
|
||||
@requireAuth()
|
||||
basicMethod() {
|
||||
return 'basic';
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
guestAllowedMethod() {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
@requireAuth({ errorMessage: 'Custom error' })
|
||||
customErrorMethod() {
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
undecorated() {
|
||||
return 'undecorated';
|
||||
}
|
||||
}
|
||||
|
||||
let instance: TestClass;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new TestClass();
|
||||
});
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store auth metadata on target', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'basicMethod');
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should store options in metadata', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'guestAllowedMethod');
|
||||
expect(metadata?.options?.allowGuest).toBe(true);
|
||||
});
|
||||
|
||||
it('should store custom error message', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'customErrorMethod');
|
||||
expect(metadata?.options?.errorMessage).toBe('Custom error');
|
||||
});
|
||||
|
||||
it('should return undefined for undecorated methods', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'undecorated');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent methods', () => {
|
||||
const metadata = getAuthMetadata(TestClass.prototype, 'nonExistent');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
expect(instance.basicMethod()).toBe('basic');
|
||||
expect(instance.guestAllowedMethod()).toBe('guest');
|
||||
expect(instance.customErrorMethod()).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('metadata key', () => {
|
||||
it('should use symbol for metadata storage', () => {
|
||||
expect(typeof AUTH_METADATA_KEY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('should store metadata in a Map', () => {
|
||||
const metadataMap = (TestClass.prototype as any)[AUTH_METADATA_KEY];
|
||||
expect(metadataMap).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireRole decorator', () => {
|
||||
class RoleTestClass {
|
||||
@requireRole('admin')
|
||||
adminOnly() {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
modOrAdmin() {
|
||||
return 'mod';
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
verifiedPremium() {
|
||||
return 'vip';
|
||||
}
|
||||
|
||||
@requireRole('player', { mode: 'any' })
|
||||
playerExplicit() {
|
||||
return 'player';
|
||||
}
|
||||
}
|
||||
|
||||
describe('single role', () => {
|
||||
it('should store single role as array', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.roles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('should set requireAuth to true', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should default to any mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||
expect(metadata?.roleMode).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple roles', () => {
|
||||
it('should store multiple roles', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'modOrAdmin');
|
||||
expect(metadata?.roles).toEqual(['moderator', 'admin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role mode', () => {
|
||||
it('should support all mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'verifiedPremium');
|
||||
expect(metadata?.roleMode).toBe('all');
|
||||
expect(metadata?.roles).toEqual(['verified', 'premium']);
|
||||
});
|
||||
|
||||
it('should support explicit any mode', () => {
|
||||
const metadata = getAuthMetadata(RoleTestClass.prototype, 'playerExplicit');
|
||||
expect(metadata?.roleMode).toBe('any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new RoleTestClass();
|
||||
expect(instance.adminOnly()).toBe('admin');
|
||||
expect(instance.modOrAdmin()).toBe('mod');
|
||||
expect(instance.verifiedPremium()).toBe('vip');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined decorators', () => {
|
||||
class CombinedTestClass {
|
||||
@requireAuth({ allowGuest: false })
|
||||
@requireRole('admin')
|
||||
combinedMethod() {
|
||||
return 'combined';
|
||||
}
|
||||
}
|
||||
|
||||
it('should merge metadata from both decorators', () => {
|
||||
const metadata = getAuthMetadata(CombinedTestClass.prototype, 'combinedMethod');
|
||||
expect(metadata?.requireAuth).toBe(true);
|
||||
expect(metadata?.roles).toEqual(['admin']);
|
||||
});
|
||||
});
|
||||
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
|
||||
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
|
||||
|
||||
describe('JwtAuthProvider', () => {
|
||||
const secret = 'test-secret-key-for-testing';
|
||||
let provider: JwtAuthProvider<{ id: string; name: string; roles: string[] }>;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = createJwtAuthProvider({
|
||||
secret,
|
||||
expiresIn: 3600
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign and verify', () => {
|
||||
it('should sign and verify a token', async () => {
|
||||
const payload = { sub: '123', name: 'Alice', roles: ['player'] };
|
||||
const token = provider.sign(payload);
|
||||
|
||||
expect(token).toBeTypeOf('string');
|
||||
expect(token.split('.')).toHaveLength(3);
|
||||
|
||||
const result = await provider.verify(token);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it('should extract user from payload', async () => {
|
||||
const payload = { sub: '123', name: 'Alice' };
|
||||
const token = provider.sign(payload);
|
||||
|
||||
const result = await provider.verify(token);
|
||||
expect(result.success).toBe(true);
|
||||
expect((result.user as any).sub).toBe('123');
|
||||
expect((result.user as any).name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return expiration time', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
const result = await provider.verify(token);
|
||||
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify errors', () => {
|
||||
it('should fail for empty token', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for invalid token', async () => {
|
||||
const result = await provider.verify('invalid.token.here');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for expired token', async () => {
|
||||
const shortLivedProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
expiresIn: -1
|
||||
});
|
||||
|
||||
const token = shortLivedProvider.sign({ sub: '123' });
|
||||
const result = await shortLivedProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for wrong secret', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
|
||||
const wrongSecretProvider = createJwtAuthProvider({
|
||||
secret: 'wrong-secret'
|
||||
});
|
||||
|
||||
const result = await wrongSecretProvider.verify(token);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with getUser callback', () => {
|
||||
it('should use getUser to transform payload', async () => {
|
||||
const customProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) || []
|
||||
})
|
||||
});
|
||||
|
||||
const token = customProvider.sign({ sub: '123', name: 'Bob', roles: ['admin'] });
|
||||
const result = await customProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual({
|
||||
id: '123',
|
||||
name: 'Bob',
|
||||
roles: ['admin']
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail when getUser returns null', async () => {
|
||||
const customProvider = createJwtAuthProvider({
|
||||
secret,
|
||||
getUser: async () => null
|
||||
});
|
||||
|
||||
const token = customProvider.sign({ sub: '123' });
|
||||
const result = await customProvider.verify(token);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh a valid token', async () => {
|
||||
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||
|
||||
// Wait a bit so iat changes
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = await provider.refresh(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.token).toBeDefined();
|
||||
expect(result.token).not.toBe(token);
|
||||
});
|
||||
|
||||
it('should return new expiration time', async () => {
|
||||
const token = provider.sign({ sub: '123' });
|
||||
const result = await provider.refresh(token);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.expiresAt).toBeTypeOf('number');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('should fail to refresh invalid token', async () => {
|
||||
const result = await provider.refresh('invalid');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode', () => {
|
||||
it('should decode token without verification', () => {
|
||||
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||
const payload = provider.decode(token);
|
||||
|
||||
expect(payload).toBeDefined();
|
||||
expect(payload?.sub).toBe('123');
|
||||
expect(payload?.name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should return null for invalid token', () => {
|
||||
const payload = provider.decode('not-a-token');
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionAuthProvider', () => {
|
||||
let storage: ISessionStorage;
|
||||
let provider: SessionAuthProvider<{ id: string; name: string }>;
|
||||
let storageData: Map<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
storageData = new Map();
|
||||
|
||||
storage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (storageData.get(key) as T) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
storageData.set(key, value);
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return storageData.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
provider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 3600000
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession and verify', () => {
|
||||
it('should create and verify a session', async () => {
|
||||
const user = { id: '123', name: 'Alice' };
|
||||
const sessionId = await provider.createSession(user);
|
||||
|
||||
expect(sessionId).toBeTypeOf('string');
|
||||
expect(sessionId.length).toBeGreaterThan(10);
|
||||
|
||||
const result = await provider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toEqual(user);
|
||||
});
|
||||
|
||||
it('should store session data', async () => {
|
||||
const user = { id: '123', name: 'Alice' };
|
||||
const sessionId = await provider.createSession(user, { customField: 'value' });
|
||||
|
||||
const session = await provider.getSession(sessionId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.user).toEqual(user);
|
||||
expect(session?.data?.customField).toBe('value');
|
||||
expect(session?.createdAt).toBeTypeOf('number');
|
||||
expect(session?.lastActiveAt).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify errors', () => {
|
||||
it('should fail for empty session id', async () => {
|
||||
const result = await provider.verify('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail for non-existent session', async () => {
|
||||
const result = await provider.verify('non-existent-session');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with validateUser', () => {
|
||||
it('should validate user on verify', async () => {
|
||||
const validatingProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
validateUser: (user) => user.id !== 'banned'
|
||||
});
|
||||
|
||||
const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
|
||||
const result = await validatingProvider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('ACCOUNT_DISABLED');
|
||||
});
|
||||
|
||||
it('should pass validation for valid user', async () => {
|
||||
const validatingProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
validateUser: (user) => user.id !== 'banned'
|
||||
});
|
||||
|
||||
const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
|
||||
const result = await validatingProvider.verify(sessionId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh session and update lastActiveAt', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const session1 = await provider.getSession(sessionId);
|
||||
const lastActive1 = session1?.lastActiveAt;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const result = await provider.refresh(sessionId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const session2 = await provider.getSession(sessionId);
|
||||
expect(session2?.lastActiveAt).toBeGreaterThanOrEqual(lastActive1!);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke session', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const revoked = await provider.revoke(sessionId);
|
||||
expect(revoked).toBe(true);
|
||||
|
||||
const result = await provider.verify(sessionId);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSession', () => {
|
||||
it('should update session data', async () => {
|
||||
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||
|
||||
const updated = await provider.updateSession(sessionId, { newField: 'newValue' });
|
||||
expect(updated).toBe(true);
|
||||
|
||||
const session = await provider.getSession(sessionId);
|
||||
expect(session?.data?.newField).toBe('newValue');
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const updated = await provider.updateSession('non-existent', { field: 'value' });
|
||||
expect(updated).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createJwtAuthProvider', () => {
|
||||
it('should create provider with default options', () => {
|
||||
const provider = createJwtAuthProvider({ secret: 'test' });
|
||||
expect(provider.name).toBe('jwt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSessionAuthProvider', () => {
|
||||
it('should create provider with default options', () => {
|
||||
const storage: ISessionStorage = {
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
delete: async () => true
|
||||
};
|
||||
|
||||
const provider = createSessionAuthProvider({ storage });
|
||||
expect(provider.name).toBe('session');
|
||||
});
|
||||
});
|
||||
202
packages/framework/server/src/auth/context.ts
Normal file
202
packages/framework/server/src/auth/context.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @zh 认证上下文实现
|
||||
* @en Authentication context implementation
|
||||
*/
|
||||
|
||||
import type { IAuthContext, AuthResult } from './types.js';
|
||||
|
||||
/**
|
||||
* @zh 用户信息提取器
|
||||
* @en User info extractor
|
||||
*/
|
||||
export interface UserInfoExtractor<TUser> {
|
||||
/**
|
||||
* @zh 提取用户 ID
|
||||
* @en Extract user ID
|
||||
*/
|
||||
getId(user: TUser): string;
|
||||
|
||||
/**
|
||||
* @zh 提取用户角色
|
||||
* @en Extract user roles
|
||||
*/
|
||||
getRoles(user: TUser): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 默认用户信息提取器
|
||||
* @en Default user info extractor
|
||||
*/
|
||||
export const defaultUserExtractor: UserInfoExtractor<unknown> = {
|
||||
getId(user: unknown): string {
|
||||
if (user && typeof user === 'object') {
|
||||
const u = user as Record<string, unknown>;
|
||||
if (typeof u.id === 'string') return u.id;
|
||||
if (typeof u.id === 'number') return String(u.id);
|
||||
if (typeof u.userId === 'string') return u.userId;
|
||||
if (typeof u.userId === 'number') return String(u.userId);
|
||||
if (typeof u.sub === 'string') return u.sub;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
getRoles(user: unknown): string[] {
|
||||
if (user && typeof user === 'object') {
|
||||
const u = user as Record<string, unknown>;
|
||||
if (Array.isArray(u.roles)) {
|
||||
return u.roles.filter((r): r is string => typeof r === 'string');
|
||||
}
|
||||
if (typeof u.role === 'string') {
|
||||
return [u.role];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*
|
||||
* @zh 存储连接的认证状态
|
||||
* @en Stores authentication state for a connection
|
||||
*/
|
||||
export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
|
||||
private _isAuthenticated: boolean = false;
|
||||
private _user: TUser | null = null;
|
||||
private _userId: string | null = null;
|
||||
private _roles: string[] = [];
|
||||
private _authenticatedAt: number | null = null;
|
||||
private _expiresAt: number | null = null;
|
||||
private _extractor: UserInfoExtractor<TUser>;
|
||||
|
||||
constructor(extractor?: UserInfoExtractor<TUser>) {
|
||||
this._extractor = (extractor ?? defaultUserExtractor) as UserInfoExtractor<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 是否已认证
|
||||
* @en Whether authenticated
|
||||
*/
|
||||
get isAuthenticated(): boolean {
|
||||
if (this._expiresAt && Date.now() > this._expiresAt) {
|
||||
return false;
|
||||
}
|
||||
return this._isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
get user(): TUser | null {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户 ID
|
||||
* @en User ID
|
||||
*/
|
||||
get userId(): string | null {
|
||||
return this._userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 用户角色
|
||||
* @en User roles
|
||||
*/
|
||||
get roles(): ReadonlyArray<string> {
|
||||
return this._roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证时间
|
||||
* @en Authentication timestamp
|
||||
*/
|
||||
get authenticatedAt(): number | null {
|
||||
return this._authenticatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间
|
||||
* @en Token expiration time
|
||||
*/
|
||||
get expiresAt(): number | null {
|
||||
return this._expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有指定角色
|
||||
* @en Check if has specified role
|
||||
*/
|
||||
hasRole(role: string): boolean {
|
||||
return this._roles.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有任一指定角色
|
||||
* @en Check if has any of specified roles
|
||||
*/
|
||||
hasAnyRole(roles: string[]): boolean {
|
||||
return roles.some(role => this._roles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有所有指定角色
|
||||
* @en Check if has all specified roles
|
||||
*/
|
||||
hasAllRoles(roles: string[]): boolean {
|
||||
return roles.every(role => this._roles.includes(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置认证结果
|
||||
* @en Set authentication result
|
||||
*/
|
||||
setAuthenticated(result: AuthResult<TUser>): void {
|
||||
if (result.success && result.user) {
|
||||
this._isAuthenticated = true;
|
||||
this._user = result.user;
|
||||
this._userId = this._extractor.getId(result.user);
|
||||
this._roles = this._extractor.getRoles(result.user);
|
||||
this._authenticatedAt = Date.now();
|
||||
this._expiresAt = result.expiresAt ?? null;
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清除认证状态
|
||||
* @en Clear authentication state
|
||||
*/
|
||||
clear(): void {
|
||||
this._isAuthenticated = false;
|
||||
this._user = null;
|
||||
this._userId = null;
|
||||
this._roles = [];
|
||||
this._authenticatedAt = null;
|
||||
this._expiresAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建访客认证上下文
|
||||
* @en Create guest auth context
|
||||
*/
|
||||
export function createGuestContext<TUser = unknown>(): IAuthContext<TUser> {
|
||||
return new AuthContext<TUser>();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从认证结果创建认证上下文
|
||||
* @en Create auth context from auth result
|
||||
*/
|
||||
export function createAuthContext<TUser = unknown>(
|
||||
result: AuthResult<TUser>,
|
||||
extractor?: UserInfoExtractor<TUser>
|
||||
): AuthContext<TUser> {
|
||||
const context = new AuthContext<TUser>(extractor);
|
||||
context.setAuthenticated(result);
|
||||
return context;
|
||||
}
|
||||
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @zh 认证装饰器
|
||||
* @en Authentication decorators
|
||||
*/
|
||||
|
||||
export {
|
||||
requireAuth,
|
||||
getAuthMetadata,
|
||||
AUTH_METADATA_KEY,
|
||||
type AuthMetadata
|
||||
} from './requireAuth.js';
|
||||
|
||||
export { requireRole } from './requireRole.js';
|
||||
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @zh requireAuth 装饰器
|
||||
* @en requireAuth decorator
|
||||
*/
|
||||
|
||||
import type { RequireAuthOptions } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 认证元数据键
|
||||
* @en Auth metadata key
|
||||
*/
|
||||
export const AUTH_METADATA_KEY = Symbol('authMetadata');
|
||||
|
||||
/**
|
||||
* @zh 认证元数据
|
||||
* @en Auth metadata
|
||||
*/
|
||||
export interface AuthMetadata {
|
||||
requireAuth: boolean;
|
||||
options?: RequireAuthOptions;
|
||||
roles?: string[];
|
||||
roleMode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取方法的认证元数据
|
||||
* @en Get auth metadata for method
|
||||
*/
|
||||
export function getAuthMetadata(target: any, propertyKey: string): AuthMetadata | undefined {
|
||||
const metadata = target[AUTH_METADATA_KEY] as Map<string, AuthMetadata> | undefined;
|
||||
return metadata?.get(propertyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置方法的认证元数据
|
||||
* @en Set auth metadata for method
|
||||
*/
|
||||
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||
if (!target[AUTH_METADATA_KEY]) {
|
||||
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||
}
|
||||
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 要求认证装饰器
|
||||
* @en Require authentication decorator
|
||||
*
|
||||
* @zh 标记方法需要认证才能访问,用于消息处理器
|
||||
* @en Marks method as requiring authentication, used for message handlers
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRoomAuth(Room) {
|
||||
* @requireAuth()
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
* // Only authenticated players can trade
|
||||
* }
|
||||
*
|
||||
* @requireAuth({ allowGuest: true })
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: ChatData, player: AuthPlayer) {
|
||||
* // Guests can also chat
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function requireAuth(options?: RequireAuthOptions): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const key = String(propertyKey);
|
||||
const existing = getAuthMetadata(target, key);
|
||||
|
||||
setAuthMetadata(target, key, {
|
||||
...existing,
|
||||
requireAuth: true,
|
||||
options
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @zh requireRole 装饰器
|
||||
* @en requireRole decorator
|
||||
*/
|
||||
|
||||
import type { RequireRoleOptions } from '../types.js';
|
||||
import { AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from './requireAuth.js';
|
||||
|
||||
/**
|
||||
* @zh 设置方法的认证元数据
|
||||
* @en Set auth metadata for method
|
||||
*/
|
||||
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||
if (!target[AUTH_METADATA_KEY]) {
|
||||
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||
}
|
||||
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 要求角色装饰器
|
||||
* @en Require role decorator
|
||||
*
|
||||
* @zh 标记方法需要特定角色才能访问
|
||||
* @en Marks method as requiring specific role(s)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class AdminRoom extends withRoomAuth(Room) {
|
||||
* @requireRole('admin')
|
||||
* @onMessage('Ban')
|
||||
* handleBan(data: BanData, player: AuthPlayer) {
|
||||
* // Only admins can ban
|
||||
* }
|
||||
*
|
||||
* @requireRole(['moderator', 'admin'])
|
||||
* @onMessage('Mute')
|
||||
* handleMute(data: MuteData, player: AuthPlayer) {
|
||||
* // Moderators or admins can mute
|
||||
* }
|
||||
*
|
||||
* @requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
* @onMessage('SpecialFeature')
|
||||
* handleSpecial(data: any, player: AuthPlayer) {
|
||||
* // Requires both verified AND premium roles
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function requireRole(
|
||||
roles: string | string[],
|
||||
options?: RequireRoleOptions
|
||||
): MethodDecorator {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const key = String(propertyKey);
|
||||
const existing = getAuthMetadata(target, key);
|
||||
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||
|
||||
setAuthMetadata(target, key, {
|
||||
...existing,
|
||||
requireAuth: true,
|
||||
roles: roleArray,
|
||||
roleMode: options?.mode ?? 'any',
|
||||
options
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
129
packages/framework/server/src/auth/index.ts
Normal file
129
packages/framework/server/src/auth/index.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @zh 认证模块
|
||||
* @en Authentication module
|
||||
*
|
||||
* @zh 为 @esengine/server 提供可插拔的认证系统
|
||||
* @en Provides pluggable authentication system for @esengine/server
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer, Room, onMessage } from '@esengine/server';
|
||||
* import {
|
||||
* withAuth,
|
||||
* withRoomAuth,
|
||||
* createJwtAuthProvider,
|
||||
* requireAuth,
|
||||
* requireRole,
|
||||
* type AuthPlayer
|
||||
* } from '@esengine/server/auth';
|
||||
*
|
||||
* // 1. Create auth provider
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: process.env.JWT_SECRET!,
|
||||
* expiresIn: 3600,
|
||||
* });
|
||||
*
|
||||
* // 2. Wrap server with auth
|
||||
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||
* provider: jwtProvider,
|
||||
* extractCredentials: (req) => {
|
||||
* const url = new URL(req.url, 'http://localhost');
|
||||
* return url.searchParams.get('token');
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // 3. Create auth-enabled room
|
||||
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player'],
|
||||
* }) {
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* console.log(`${player.user?.name} joined`);
|
||||
* }
|
||||
*
|
||||
* @requireAuth()
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
* this.broadcast('Chat', { from: player.user?.name, text: data.text });
|
||||
* }
|
||||
*
|
||||
* @requireRole('admin')
|
||||
* @onMessage('Kick')
|
||||
* handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
* this.kick(data.playerId);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* server.define('game', GameRoom);
|
||||
* await server.start();
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AuthResult,
|
||||
AuthErrorCode,
|
||||
IAuthProvider,
|
||||
IAuthContext,
|
||||
AuthConnectionData,
|
||||
AuthConnection,
|
||||
AuthApiContext,
|
||||
AuthMsgContext,
|
||||
ConnectionRequest,
|
||||
AuthServerConfig,
|
||||
AuthGameServer,
|
||||
AuthRoomConfig,
|
||||
RequireAuthOptions,
|
||||
RequireRoleOptions
|
||||
} from './types.js';
|
||||
|
||||
// Context
|
||||
export {
|
||||
AuthContext,
|
||||
createGuestContext,
|
||||
createAuthContext,
|
||||
defaultUserExtractor,
|
||||
type UserInfoExtractor
|
||||
} from './context.js';
|
||||
|
||||
// Providers
|
||||
export {
|
||||
JwtAuthProvider,
|
||||
createJwtAuthProvider,
|
||||
type JwtAuthConfig,
|
||||
type JwtPayload
|
||||
} from './providers/JwtAuthProvider.js';
|
||||
|
||||
export {
|
||||
SessionAuthProvider,
|
||||
createSessionAuthProvider,
|
||||
type SessionAuthConfig,
|
||||
type SessionData,
|
||||
type ISessionStorage
|
||||
} from './providers/SessionAuthProvider.js';
|
||||
|
||||
// Mixins
|
||||
export {
|
||||
withAuth,
|
||||
getAuthContext,
|
||||
setAuthContext,
|
||||
requireAuthentication,
|
||||
requireRole as requireRoleCheck
|
||||
} from './mixin/withAuth.js';
|
||||
|
||||
export {
|
||||
withRoomAuth,
|
||||
AuthRoomBase,
|
||||
type AuthPlayer,
|
||||
type IAuthRoom,
|
||||
type AuthRoomClass
|
||||
} from './mixin/withRoomAuth.js';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
getAuthMetadata,
|
||||
AUTH_METADATA_KEY,
|
||||
type AuthMetadata
|
||||
} from './decorators/index.js';
|
||||
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @zh 认证 Mixin
|
||||
* @en Authentication mixins
|
||||
*/
|
||||
|
||||
export {
|
||||
withAuth,
|
||||
getAuthContext,
|
||||
setAuthContext,
|
||||
requireAuthentication,
|
||||
requireRole
|
||||
} from './withAuth.js';
|
||||
|
||||
export {
|
||||
withRoomAuth,
|
||||
AuthRoomBase,
|
||||
type AuthPlayer,
|
||||
type IAuthRoom,
|
||||
type AuthRoomClass
|
||||
} from './withRoomAuth.js';
|
||||
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @zh 服务器认证 Mixin
|
||||
* @en Server authentication mixin
|
||||
*/
|
||||
|
||||
import type { ServerConnection, GameServer } from '../../types/index.js';
|
||||
import type {
|
||||
IAuthProvider,
|
||||
AuthResult,
|
||||
AuthServerConfig,
|
||||
AuthGameServer,
|
||||
IAuthContext,
|
||||
ConnectionRequest
|
||||
} from '../types.js';
|
||||
import { AuthContext } from '../context.js';
|
||||
|
||||
/**
|
||||
* @zh 认证数据键
|
||||
* @en Auth data key
|
||||
*/
|
||||
const AUTH_CONTEXT_KEY = Symbol('authContext');
|
||||
|
||||
/**
|
||||
* @zh 获取连接的认证上下文
|
||||
* @en Get auth context for connection
|
||||
*/
|
||||
export function getAuthContext<TUser = unknown>(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||
const data = conn.data as Record<symbol, unknown>;
|
||||
return (data[AUTH_CONTEXT_KEY] as IAuthContext<TUser>) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置连接的认证上下文
|
||||
* @en Set auth context for connection
|
||||
*/
|
||||
export function setAuthContext<TUser = unknown>(conn: ServerConnection, context: IAuthContext<TUser>): void {
|
||||
const data = conn.data as Record<symbol, unknown>;
|
||||
data[AUTH_CONTEXT_KEY] = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装服务器添加认证功能
|
||||
* @en Wrap server with authentication functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为服务器添加认证能力,不修改原始服务器
|
||||
* @en Uses mixin pattern to add authentication to server without modifying original
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createServer } from '@esengine/server';
|
||||
* import { withAuth, createJwtAuthProvider } from '@esengine/server/auth';
|
||||
*
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: 'your-secret-key',
|
||||
* expiresIn: 3600,
|
||||
* });
|
||||
*
|
||||
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||
* provider: jwtProvider,
|
||||
* extractCredentials: (req) => {
|
||||
* const url = new URL(req.url, 'http://localhost');
|
||||
* return url.searchParams.get('token');
|
||||
* },
|
||||
* onAuthSuccess: (conn, user) => {
|
||||
* console.log(`User ${user.name} authenticated`);
|
||||
* },
|
||||
* onAuthFailure: (conn, error) => {
|
||||
* console.log(`Auth failed: ${error.error}`);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* await server.start();
|
||||
* ```
|
||||
*/
|
||||
export function withAuth<TUser = unknown>(
|
||||
server: GameServer,
|
||||
config: AuthServerConfig<TUser>
|
||||
): AuthGameServer<TUser> {
|
||||
const {
|
||||
provider,
|
||||
extractCredentials,
|
||||
autoAuthOnConnect = true,
|
||||
disconnectOnAuthFailure = false,
|
||||
onAuthSuccess,
|
||||
onAuthFailure
|
||||
} = config;
|
||||
|
||||
const originalConnections = server.connections;
|
||||
const connectionAuthMap = new WeakMap<ServerConnection, AuthContext<TUser>>();
|
||||
|
||||
const authServer: AuthGameServer<TUser> = {
|
||||
...server,
|
||||
|
||||
get authProvider(): IAuthProvider<TUser> {
|
||||
return provider;
|
||||
},
|
||||
|
||||
async authenticate(
|
||||
conn: ServerConnection,
|
||||
credentials: unknown
|
||||
): Promise<AuthResult<TUser>> {
|
||||
const result = await provider.verify(credentials as never);
|
||||
|
||||
let authContext = connectionAuthMap.get(conn);
|
||||
if (!authContext) {
|
||||
authContext = new AuthContext<TUser>();
|
||||
connectionAuthMap.set(conn, authContext);
|
||||
setAuthContext(conn, authContext);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
authContext.setAuthenticated(result);
|
||||
await onAuthSuccess?.(conn, result.user!);
|
||||
} else {
|
||||
authContext.clear();
|
||||
await onAuthFailure?.(conn, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||
return connectionAuthMap.get(conn) ?? null;
|
||||
},
|
||||
|
||||
get connections(): ReadonlyArray<ServerConnection> {
|
||||
return originalConnections;
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnConnect = (server as any)._onConnect;
|
||||
(server as any)._onConnect = async (conn: ServerConnection, req?: unknown) => {
|
||||
const authContext = new AuthContext<TUser>();
|
||||
connectionAuthMap.set(conn, authContext);
|
||||
setAuthContext(conn, authContext);
|
||||
|
||||
if (autoAuthOnConnect && extractCredentials && req) {
|
||||
try {
|
||||
const connReq = req as ConnectionRequest;
|
||||
const credentials = await extractCredentials(connReq);
|
||||
|
||||
if (credentials) {
|
||||
const result = await provider.verify(credentials as never);
|
||||
|
||||
if (result.success) {
|
||||
authContext.setAuthenticated(result);
|
||||
await onAuthSuccess?.(conn, result.user!);
|
||||
} else {
|
||||
await onAuthFailure?.(conn, result);
|
||||
|
||||
if (disconnectOnAuthFailure) {
|
||||
(conn as any).close?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error during auto-authentication:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalOnConnect) {
|
||||
await originalOnConnect(conn, req);
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnDisconnect = (server as any)._onDisconnect;
|
||||
(server as any)._onDisconnect = async (conn: ServerConnection) => {
|
||||
connectionAuthMap.delete(conn);
|
||||
|
||||
if (originalOnDisconnect) {
|
||||
await originalOnDisconnect(conn);
|
||||
}
|
||||
};
|
||||
|
||||
return authServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建认证中间件
|
||||
* @en Create authentication middleware
|
||||
*
|
||||
* @zh 用于在 API 处理器中检查认证状态
|
||||
* @en Used to check authentication status in API handlers
|
||||
*/
|
||||
export function requireAuthentication<TUser = unknown>(
|
||||
conn: ServerConnection,
|
||||
options?: { errorMessage?: string }
|
||||
): IAuthContext<TUser> {
|
||||
const auth = getAuthContext<TUser>(conn);
|
||||
|
||||
if (!auth || !auth.isAuthenticated) {
|
||||
throw new Error(options?.errorMessage ?? 'Authentication required');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建角色检查中间件
|
||||
* @en Create role check middleware
|
||||
*/
|
||||
export function requireRole<TUser = unknown>(
|
||||
conn: ServerConnection,
|
||||
roles: string | string[],
|
||||
options?: { mode?: 'any' | 'all'; errorMessage?: string }
|
||||
): IAuthContext<TUser> {
|
||||
const auth = requireAuthentication<TUser>(conn);
|
||||
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||
const mode = options?.mode ?? 'any';
|
||||
|
||||
const hasRole = mode === 'any'
|
||||
? auth.hasAnyRole(roleArray)
|
||||
: auth.hasAllRoles(roleArray);
|
||||
|
||||
if (!hasRole) {
|
||||
throw new Error(options?.errorMessage ?? 'Insufficient permissions');
|
||||
}
|
||||
|
||||
return auth;
|
||||
}
|
||||
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* @zh 房间认证 Mixin
|
||||
* @en Room authentication mixin
|
||||
*/
|
||||
|
||||
import type { Room, Player } from '../../room/index.js';
|
||||
import type { IAuthContext, AuthRoomConfig } from '../types.js';
|
||||
import { getAuthContext } from './withAuth.js';
|
||||
import { createGuestContext } from '../context.js';
|
||||
|
||||
/**
|
||||
* @zh 带认证的玩家
|
||||
* @en Player with authentication
|
||||
*/
|
||||
export interface AuthPlayer<TUser = unknown, TData = Record<string, unknown>> extends Player<TData> {
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*/
|
||||
readonly auth: IAuthContext<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 用户信息(快捷访问)
|
||||
* @en User info (shortcut)
|
||||
*/
|
||||
readonly user: TUser | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的房间接口
|
||||
* @en Room with authentication interface
|
||||
*/
|
||||
export interface IAuthRoom<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证钩子(在 onJoin 之前调用)
|
||||
* @en Auth hook (called before onJoin)
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 获取带认证信息的玩家
|
||||
* @en Get player with auth info
|
||||
*/
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined;
|
||||
|
||||
/**
|
||||
* @zh 获取所有带认证信息的玩家
|
||||
* @en Get all players with auth info
|
||||
*/
|
||||
getAuthPlayers(): AuthPlayer<TUser>[];
|
||||
|
||||
/**
|
||||
* @zh 按角色获取玩家
|
||||
* @en Get players by role
|
||||
*/
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[];
|
||||
|
||||
/**
|
||||
* @zh 按用户 ID 获取玩家
|
||||
* @en Get player by user ID
|
||||
*/
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证房间构造器类型
|
||||
* @en Auth room constructor type
|
||||
*/
|
||||
export type AuthRoomClass<TUser = unknown> = new (...args: any[]) => Room & IAuthRoom<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 玩家认证上下文存储
|
||||
* @en Player auth context storage
|
||||
*/
|
||||
const playerAuthContexts = new WeakMap<Player, IAuthContext<unknown>>();
|
||||
|
||||
/**
|
||||
* @zh 包装玩家对象添加认证信息
|
||||
* @en Wrap player object with auth info
|
||||
*/
|
||||
function wrapPlayerWithAuth<TUser>(player: Player, authContext: IAuthContext<TUser>): AuthPlayer<TUser> {
|
||||
playerAuthContexts.set(player, authContext);
|
||||
|
||||
Object.defineProperty(player, 'auth', {
|
||||
get: () => playerAuthContexts.get(player) ?? createGuestContext<TUser>(),
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
Object.defineProperty(player, 'user', {
|
||||
get: () => (playerAuthContexts.get(player) as IAuthContext<TUser> | undefined)?.user ?? null,
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
return player as AuthPlayer<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装房间类添加认证功能
|
||||
* @en Wrap room class with authentication functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为房间添加认证检查,在玩家加入前验证认证状态
|
||||
* @en Uses mixin pattern to add auth checks to room, validates auth before player joins
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth';
|
||||
*
|
||||
* interface User {
|
||||
* id: string;
|
||||
* name: string;
|
||||
* roles: string[];
|
||||
* }
|
||||
*
|
||||
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player', 'premium'],
|
||||
* }) {
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* console.log(`${player.user?.name} joined the game`);
|
||||
* this.broadcast('PlayerJoined', {
|
||||
* id: player.id,
|
||||
* name: player.user?.name
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* // Optional: custom auth validation
|
||||
* async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
* // Additional validation logic
|
||||
* if (player.auth.hasRole('banned')) {
|
||||
* return false;
|
||||
* }
|
||||
* return true;
|
||||
* }
|
||||
*
|
||||
* @onMessage('Chat')
|
||||
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
* this.broadcast('Chat', {
|
||||
* from: player.user?.name ?? 'Guest',
|
||||
* text: data.text
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||
Base: TBase,
|
||||
config: AuthRoomConfig = {}
|
||||
): TBase & (new (...args: any[]) => IAuthRoom<TUser>) {
|
||||
const {
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
roleCheckMode = 'any'
|
||||
} = config;
|
||||
|
||||
abstract class AuthRoom extends (Base as new (...args: any[]) => Room) implements IAuthRoom<TUser> {
|
||||
private _originalOnJoin: ((player: Player) => void | Promise<void>) | undefined;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
this._originalOnJoin = this.onJoin?.bind(this);
|
||||
this.onJoin = this._authOnJoin.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证钩子(可覆盖)
|
||||
* @en Auth hook (can be overridden)
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 包装的 onJoin 方法
|
||||
* @en Wrapped onJoin method
|
||||
*/
|
||||
private async _authOnJoin(player: Player): Promise<void> {
|
||||
const conn = (player as any).connection ?? (player as any)._conn;
|
||||
const authContext = conn
|
||||
? (getAuthContext<TUser>(conn) ?? createGuestContext<TUser>())
|
||||
: createGuestContext<TUser>();
|
||||
|
||||
if (requireAuth && !authContext.isAuthenticated) {
|
||||
console.warn(`[AuthRoom] Rejected unauthenticated player: ${player.id}`);
|
||||
this.kick(player as any, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedRoles.length > 0) {
|
||||
const hasRole = roleCheckMode === 'any'
|
||||
? authContext.hasAnyRole(allowedRoles)
|
||||
: authContext.hasAllRoles(allowedRoles);
|
||||
|
||||
if (!hasRole) {
|
||||
console.warn(`[AuthRoom] Rejected player ${player.id}: insufficient roles`);
|
||||
this.kick(player as any, 'Insufficient permissions');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const authPlayer = wrapPlayerWithAuth<TUser>(player, authContext);
|
||||
|
||||
if (typeof this.onAuth === 'function') {
|
||||
try {
|
||||
const allowed = await this.onAuth(authPlayer);
|
||||
if (!allowed) {
|
||||
console.warn(`[AuthRoom] Rejected player ${player.id}: onAuth returned false`);
|
||||
this.kick(player as any, 'Authentication rejected');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AuthRoom] Error in onAuth for player ${player.id}:`, error);
|
||||
this.kick(player as any, 'Authentication error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._originalOnJoin) {
|
||||
await this._originalOnJoin(authPlayer as unknown as Player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取带认证信息的玩家
|
||||
* @en Get player with auth info
|
||||
*/
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||
const player = this.getPlayer(id);
|
||||
return player as AuthPlayer<TUser> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有带认证信息的玩家
|
||||
* @en Get all players with auth info
|
||||
*/
|
||||
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||
return this.players as AuthPlayer<TUser>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 按角色获取玩家
|
||||
* @en Get players by role
|
||||
*/
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||
return this.getAuthPlayers().filter(p => p.auth?.hasRole(role));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 按用户 ID 获取玩家
|
||||
* @en Get player by user ID
|
||||
*/
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||
return this.getAuthPlayers().find(p => p.auth?.userId === userId);
|
||||
}
|
||||
}
|
||||
|
||||
return AuthRoom as unknown as TBase & (new (...args: any[]) => IAuthRoom<TUser>);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 抽象认证房间基类
|
||||
* @en Abstract auth room base class
|
||||
*
|
||||
* @zh 如果不想使用 mixin,可以直接继承此类
|
||||
* @en If you don't want to use mixin, you can extend this class directly
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { AuthRoomBase } from '@esengine/server/auth';
|
||||
*
|
||||
* class GameRoom extends AuthRoomBase<User> {
|
||||
* protected readonly authConfig = {
|
||||
* requireAuth: true,
|
||||
* allowedRoles: ['player']
|
||||
* };
|
||||
*
|
||||
* onJoin(player: AuthPlayer<User>) {
|
||||
* // player has .auth and .user properties
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
|
||||
implements IAuthRoom<TUser> {
|
||||
|
||||
/**
|
||||
* @zh 认证配置(子类可覆盖)
|
||||
* @en Auth config (can be overridden by subclass)
|
||||
*/
|
||||
protected readonly authConfig: AuthRoomConfig = {
|
||||
requireAuth: true,
|
||||
allowedRoles: [],
|
||||
roleCheckMode: 'any'
|
||||
};
|
||||
|
||||
/**
|
||||
* @zh 认证钩子
|
||||
* @en Auth hook
|
||||
*/
|
||||
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||
|
||||
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @zh 认证提供者接口
|
||||
* @en Authentication provider interface
|
||||
*/
|
||||
|
||||
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @zh JWT 认证提供者
|
||||
* @en JWT authentication provider
|
||||
*/
|
||||
|
||||
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* @zh JWT 载荷
|
||||
* @en JWT payload
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
/**
|
||||
* @zh 主题(用户 ID)
|
||||
* @en Subject (user ID)
|
||||
*/
|
||||
sub?: string;
|
||||
|
||||
/**
|
||||
* @zh 签发时间
|
||||
* @en Issued at
|
||||
*/
|
||||
iat?: number;
|
||||
|
||||
/**
|
||||
* @zh 过期时间
|
||||
* @en Expiration time
|
||||
*/
|
||||
exp?: number;
|
||||
|
||||
/**
|
||||
* @zh 自定义字段
|
||||
* @en Custom fields
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh JWT 认证配置
|
||||
* @en JWT authentication configuration
|
||||
*/
|
||||
export interface JwtAuthConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh 密钥
|
||||
* @en Secret key
|
||||
*/
|
||||
secret: string;
|
||||
|
||||
/**
|
||||
* @zh 算法
|
||||
* @en Algorithm
|
||||
* @defaultValue 'HS256'
|
||||
*/
|
||||
algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间(秒)
|
||||
* @en Token expiration in seconds
|
||||
* @defaultValue 3600
|
||||
*/
|
||||
expiresIn?: number;
|
||||
|
||||
/**
|
||||
* @zh 从载荷获取用户信息
|
||||
* @en Get user from payload
|
||||
*/
|
||||
getUser?: (payload: JwtPayload) => TUser | Promise<TUser | null> | null;
|
||||
|
||||
/**
|
||||
* @zh 签发者
|
||||
* @en Issuer
|
||||
*/
|
||||
issuer?: string;
|
||||
|
||||
/**
|
||||
* @zh 受众
|
||||
* @en Audience
|
||||
*/
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh JWT 认证提供者
|
||||
* @en JWT authentication provider
|
||||
*
|
||||
* @zh 使用 jsonwebtoken 库实现 JWT 认证
|
||||
* @en Uses jsonwebtoken library for JWT authentication
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: 'your-secret-key',
|
||||
* expiresIn: 3600,
|
||||
* getUser: async (payload) => {
|
||||
* return await db.users.findById(payload.sub);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class JwtAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||
readonly name = 'jwt';
|
||||
|
||||
private _config: Required<Pick<JwtAuthConfig<TUser>, 'secret' | 'algorithm' | 'expiresIn'>> & JwtAuthConfig<TUser>;
|
||||
|
||||
constructor(config: JwtAuthConfig<TUser>) {
|
||||
this._config = {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: 3600,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证令牌
|
||||
* @en Verify token
|
||||
*/
|
||||
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyOptions: jwt.VerifyOptions = {
|
||||
algorithms: [this._config.algorithm]
|
||||
};
|
||||
if (this._config.issuer) {
|
||||
verifyOptions.issuer = this._config.issuer;
|
||||
}
|
||||
if (this._config.audience) {
|
||||
verifyOptions.audience = this._config.audience;
|
||||
}
|
||||
const payload = jwt.verify(token, this._config.secret, verifyOptions) as JwtPayload;
|
||||
|
||||
let user: TUser | null = null;
|
||||
|
||||
if (this._config.getUser) {
|
||||
user = await this._config.getUser(payload);
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
user = payload as unknown as TUser;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
token,
|
||||
expiresAt: payload.exp ? payload.exp * 1000 : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token has expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Invalid token',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌
|
||||
* @en Refresh token
|
||||
*/
|
||||
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||
const result = await this.verify(token);
|
||||
|
||||
if (!result.success || !result.user) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const payload = jwt.decode(token) as JwtPayload;
|
||||
|
||||
// Remove JWT standard claims that will be regenerated
|
||||
const { iat, exp, nbf, ...restPayload } = payload;
|
||||
|
||||
const newToken = this.sign(restPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: result.user,
|
||||
token: newToken,
|
||||
expiresAt: Date.now() + this._config.expiresIn * 1000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成令牌
|
||||
* @en Generate token
|
||||
*/
|
||||
sign(payload: Record<string, unknown>): string {
|
||||
const signOptions: jwt.SignOptions = {
|
||||
algorithm: this._config.algorithm,
|
||||
expiresIn: this._config.expiresIn
|
||||
};
|
||||
if (this._config.issuer) {
|
||||
signOptions.issuer = this._config.issuer;
|
||||
}
|
||||
if (this._config.audience) {
|
||||
signOptions.audience = this._config.audience;
|
||||
}
|
||||
return jwt.sign(payload, this._config.secret, signOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 解码令牌(不验证)
|
||||
* @en Decode token (without verification)
|
||||
*/
|
||||
decode(token: string): JwtPayload | null {
|
||||
return jwt.decode(token) as JwtPayload | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 JWT 认证提供者
|
||||
* @en Create JWT authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createJwtAuthProvider } from '@esengine/server/auth';
|
||||
*
|
||||
* const jwtProvider = createJwtAuthProvider({
|
||||
* secret: process.env.JWT_SECRET!,
|
||||
* expiresIn: 3600,
|
||||
* getUser: async (payload) => {
|
||||
* return { id: payload.sub, name: payload.name };
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createJwtAuthProvider<TUser = unknown>(
|
||||
config: JwtAuthConfig<TUser>
|
||||
): JwtAuthProvider<TUser> {
|
||||
return new JwtAuthProvider<TUser>(config);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* @zh Session 认证提供者
|
||||
* @en Session authentication provider
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { IAuthProvider, AuthResult } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh Session 存储接口(兼容 ITransactionStorage)
|
||||
* @en Session storage interface (compatible with ITransactionStorage)
|
||||
*/
|
||||
export interface ISessionStorage {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 数据
|
||||
* @en Session data
|
||||
*/
|
||||
export interface SessionData<TUser = unknown> {
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
user: TUser;
|
||||
|
||||
/**
|
||||
* @zh 创建时间
|
||||
* @en Created at
|
||||
*/
|
||||
createdAt: number;
|
||||
|
||||
/**
|
||||
* @zh 最后活跃时间
|
||||
* @en Last active at
|
||||
*/
|
||||
lastActiveAt: number;
|
||||
|
||||
/**
|
||||
* @zh 自定义数据
|
||||
* @en Custom data
|
||||
*/
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 认证配置
|
||||
* @en Session authentication configuration
|
||||
*/
|
||||
export interface SessionAuthConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh Session 存储
|
||||
* @en Session storage
|
||||
*/
|
||||
storage: ISessionStorage;
|
||||
|
||||
/**
|
||||
* @zh Session 过期时间(毫秒)
|
||||
* @en Session expiration in milliseconds
|
||||
* @defaultValue 86400000 (24h)
|
||||
*/
|
||||
sessionTTL?: number;
|
||||
|
||||
/**
|
||||
* @zh Session 键前缀
|
||||
* @en Session key prefix
|
||||
* @defaultValue 'session:'
|
||||
*/
|
||||
prefix?: string;
|
||||
|
||||
/**
|
||||
* @zh 验证用户(可选额外验证)
|
||||
* @en Validate user (optional extra validation)
|
||||
*/
|
||||
validateUser?: (user: TUser) => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* @zh 是否自动续期
|
||||
* @en Auto renew session
|
||||
* @defaultValue true
|
||||
*/
|
||||
autoRenew?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh Session 认证提供者
|
||||
* @en Session authentication provider
|
||||
*
|
||||
* @zh 基于存储的会话认证,支持 Redis、MongoDB 等后端
|
||||
* @en Storage-based session authentication, supports Redis, MongoDB, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||
* import { createRedisStorage } from '@esengine/transaction';
|
||||
*
|
||||
* const sessionProvider = createSessionAuthProvider({
|
||||
* storage: createRedisStorage({ factory: () => new Redis() }),
|
||||
* sessionTTL: 86400000, // 24 hours
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class SessionAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||
readonly name = 'session';
|
||||
|
||||
private _config: Required<Pick<SessionAuthConfig<TUser>, 'sessionTTL' | 'prefix' | 'autoRenew'>> & SessionAuthConfig<TUser>;
|
||||
|
||||
constructor(config: SessionAuthConfig<TUser>) {
|
||||
this._config = {
|
||||
sessionTTL: 86400000,
|
||||
prefix: 'session:',
|
||||
autoRenew: true,
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取存储键
|
||||
* @en Get storage key
|
||||
*/
|
||||
private _getKey(sessionId: string): string {
|
||||
return `${this._config.prefix}${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证 Session
|
||||
* @en Verify session
|
||||
*/
|
||||
async verify(sessionId: string): Promise<AuthResult<TUser>> {
|
||||
if (!sessionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session ID is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Session not found or expired',
|
||||
errorCode: 'EXPIRED_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.validateUser) {
|
||||
const isValid = await this._config.validateUser(session.user);
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'User validation failed',
|
||||
errorCode: 'ACCOUNT_DISABLED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this._config.autoRenew) {
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: session.user,
|
||||
token: sessionId,
|
||||
expiresAt: session.createdAt + this._config.sessionTTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新 Session
|
||||
* @en Refresh session
|
||||
*/
|
||||
async refresh(sessionId: string): Promise<AuthResult<TUser>> {
|
||||
const result = await this.verify(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
expiresAt: Date.now() + this._config.sessionTTL
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 撤销 Session
|
||||
* @en Revoke session
|
||||
*/
|
||||
async revoke(sessionId: string): Promise<boolean> {
|
||||
const key = this._getKey(sessionId);
|
||||
return await this._config.storage.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Session
|
||||
* @en Create session
|
||||
*/
|
||||
async createSession(user: TUser, data?: Record<string, unknown>): Promise<string> {
|
||||
const sessionId = this._generateSessionId();
|
||||
const key = this._getKey(sessionId);
|
||||
|
||||
const session: SessionData<TUser> = {
|
||||
user,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
data
|
||||
};
|
||||
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取 Session 数据
|
||||
* @en Get session data
|
||||
*/
|
||||
async getSession(sessionId: string): Promise<SessionData<TUser> | null> {
|
||||
const key = this._getKey(sessionId);
|
||||
return await this._config.storage.get<SessionData<TUser>>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 更新 Session 数据
|
||||
* @en Update session data
|
||||
*/
|
||||
async updateSession(sessionId: string, data: Record<string, unknown>): Promise<boolean> {
|
||||
const key = this._getKey(sessionId);
|
||||
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.data = { ...session.data, ...data };
|
||||
session.lastActiveAt = Date.now();
|
||||
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成 Session ID(使用加密安全的随机数)
|
||||
* @en Generate session ID (using cryptographically secure random)
|
||||
*/
|
||||
private _generateSessionId(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建 Session 认证提供者
|
||||
* @en Create session authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||
* import { MemoryStorage } from '@esengine/transaction';
|
||||
*
|
||||
* const sessionProvider = createSessionAuthProvider({
|
||||
* storage: new MemoryStorage(),
|
||||
* sessionTTL: 3600000, // 1 hour
|
||||
* });
|
||||
*
|
||||
* // Create a session
|
||||
* const sessionId = await sessionProvider.createSession({ id: '1', name: 'Alice' });
|
||||
*
|
||||
* // Verify session
|
||||
* const result = await sessionProvider.verify(sessionId);
|
||||
* ```
|
||||
*/
|
||||
export function createSessionAuthProvider<TUser = unknown>(
|
||||
config: SessionAuthConfig<TUser>
|
||||
): SessionAuthProvider<TUser> {
|
||||
return new SessionAuthProvider<TUser>(config);
|
||||
}
|
||||
8
packages/framework/server/src/auth/providers/index.ts
Normal file
8
packages/framework/server/src/auth/providers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication providers
|
||||
*/
|
||||
|
||||
export type { IAuthProvider, AuthResult, AuthErrorCode } from './IAuthProvider.js';
|
||||
export { JwtAuthProvider, createJwtAuthProvider, type JwtAuthConfig, type JwtPayload } from './JwtAuthProvider.js';
|
||||
export { SessionAuthProvider, createSessionAuthProvider, type SessionAuthConfig, type SessionData, type ISessionStorage } from './SessionAuthProvider.js';
|
||||
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @zh 模拟认证提供者
|
||||
* @en Mock authentication provider
|
||||
*/
|
||||
|
||||
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 模拟用户
|
||||
* @en Mock user
|
||||
*/
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
name?: string;
|
||||
roles?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟认证配置
|
||||
* @en Mock authentication configuration
|
||||
*/
|
||||
export interface MockAuthConfig {
|
||||
/**
|
||||
* @zh 预设用户列表
|
||||
* @en Preset user list
|
||||
*/
|
||||
users?: MockUser[];
|
||||
|
||||
/**
|
||||
* @zh 默认用户(无 token 时返回)
|
||||
* @en Default user (returned when no token)
|
||||
*/
|
||||
defaultUser?: MockUser;
|
||||
|
||||
/**
|
||||
* @zh 模拟延迟(毫秒)
|
||||
* @en Simulated delay (ms)
|
||||
*/
|
||||
delay?: number;
|
||||
|
||||
/**
|
||||
* @zh 是否自动创建用户
|
||||
* @en Auto create users
|
||||
*/
|
||||
autoCreate?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 验证令牌格式
|
||||
* @en Token format validator
|
||||
*/
|
||||
validateToken?: (token: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟认证提供者
|
||||
* @en Mock authentication provider
|
||||
*
|
||||
* @zh 用于测试的认证提供者,不需要真实的 JWT 或数据库
|
||||
* @en Authentication provider for testing, no real JWT or database required
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createMockAuthProvider } from '@esengine/server/auth/testing';
|
||||
*
|
||||
* const mockProvider = createMockAuthProvider({
|
||||
* users: [
|
||||
* { id: '1', name: 'Alice', roles: ['player'] },
|
||||
* { id: '2', name: 'Bob', roles: ['admin'] },
|
||||
* ],
|
||||
* autoCreate: true, // Unknown tokens create guest users
|
||||
* });
|
||||
*
|
||||
* // Verify with user ID as token
|
||||
* const result = await mockProvider.verify('1');
|
||||
* // result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
* ```
|
||||
*/
|
||||
export class MockAuthProvider<TUser extends MockUser = MockUser>
|
||||
implements IAuthProvider<TUser, string> {
|
||||
|
||||
readonly name = 'mock';
|
||||
|
||||
private _users: Map<string, TUser>;
|
||||
private _config: MockAuthConfig;
|
||||
private _revokedTokens: Set<string> = new Set();
|
||||
|
||||
constructor(config: MockAuthConfig = {}) {
|
||||
this._config = config;
|
||||
this._users = new Map();
|
||||
|
||||
if (config.users) {
|
||||
for (const user of config.users) {
|
||||
this._users.set(user.id, user as TUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 模拟延迟
|
||||
* @en Simulate delay
|
||||
*/
|
||||
private async _delay(): Promise<void> {
|
||||
if (this._config.delay && this._config.delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this._config.delay));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 验证令牌
|
||||
* @en Verify token
|
||||
*/
|
||||
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||
await this._delay();
|
||||
|
||||
if (!token) {
|
||||
if (this._config.defaultUser) {
|
||||
return {
|
||||
success: true,
|
||||
user: this._config.defaultUser as TUser,
|
||||
token: 'default'
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token is required',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._revokedTokens.has(token)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Token has been revoked',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.validateToken && !this._config.validateToken(token)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid token format',
|
||||
errorCode: 'INVALID_TOKEN'
|
||||
};
|
||||
}
|
||||
|
||||
const user = this._users.get(token);
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
token,
|
||||
expiresAt: Date.now() + 3600000
|
||||
};
|
||||
}
|
||||
|
||||
if (this._config.autoCreate) {
|
||||
const newUser = {
|
||||
id: token,
|
||||
name: `User_${token}`,
|
||||
roles: ['guest']
|
||||
} as TUser;
|
||||
|
||||
this._users.set(token, newUser);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
token,
|
||||
expiresAt: Date.now() + 3600000
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
errorCode: 'USER_NOT_FOUND'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌
|
||||
* @en Refresh token
|
||||
*/
|
||||
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||
return this.verify(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 撤销令牌
|
||||
* @en Revoke token
|
||||
*/
|
||||
async revoke(token: string): Promise<boolean> {
|
||||
this._revokedTokens.add(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 添加用户
|
||||
* @en Add user
|
||||
*/
|
||||
addUser(user: TUser): void {
|
||||
this._users.set(user.id, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除用户
|
||||
* @en Remove user
|
||||
*/
|
||||
removeUser(id: string): boolean {
|
||||
return this._users.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取用户
|
||||
* @en Get user
|
||||
*/
|
||||
getUser(id: string): TUser | undefined {
|
||||
return this._users.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取所有用户
|
||||
* @en Get all users
|
||||
*/
|
||||
getUsers(): TUser[] {
|
||||
return Array.from(this._users.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清空所有状态
|
||||
* @en Clear all state
|
||||
*/
|
||||
clear(): void {
|
||||
this._users.clear();
|
||||
this._revokedTokens.clear();
|
||||
|
||||
if (this._config.users) {
|
||||
for (const user of this._config.users) {
|
||||
this._users.set(user.id, user as TUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 生成测试令牌
|
||||
* @en Generate test token
|
||||
*/
|
||||
generateToken(userId: string): string {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建模拟认证提供者
|
||||
* @en Create mock authentication provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const provider = createMockAuthProvider({
|
||||
* users: [
|
||||
* { id: 'admin', name: 'Admin', roles: ['admin'] }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* // Use in tests
|
||||
* const server = withAuth(await createServer({ port: 0 }), {
|
||||
* provider,
|
||||
* extractCredentials: (req) => req.headers['x-token']
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockAuthProvider<TUser extends MockUser = MockUser>(
|
||||
config?: MockAuthConfig
|
||||
): MockAuthProvider<TUser> {
|
||||
return new MockAuthProvider<TUser>(config);
|
||||
}
|
||||
11
packages/framework/server/src/auth/testing/index.ts
Normal file
11
packages/framework/server/src/auth/testing/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @zh 认证测试工具
|
||||
* @en Authentication testing utilities
|
||||
*/
|
||||
|
||||
export {
|
||||
MockAuthProvider,
|
||||
createMockAuthProvider,
|
||||
type MockUser,
|
||||
type MockAuthConfig
|
||||
} from './MockAuthProvider.js';
|
||||
400
packages/framework/server/src/auth/types.ts
Normal file
400
packages/framework/server/src/auth/types.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* @zh 认证系统类型定义
|
||||
* @en Authentication system type definitions
|
||||
*/
|
||||
|
||||
import type { ServerConnection, ApiContext, MsgContext, GameServer } from '../types/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Error Codes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证错误代码
|
||||
* @en Authentication error codes
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS'
|
||||
| 'EXPIRED_TOKEN'
|
||||
| 'INVALID_TOKEN'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'ACCOUNT_DISABLED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INSUFFICIENT_PERMISSIONS';
|
||||
|
||||
// ============================================================================
|
||||
// Auth Result
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证结果
|
||||
* @en Authentication result
|
||||
*/
|
||||
export interface AuthResult<TUser = unknown> {
|
||||
/**
|
||||
* @zh 是否成功
|
||||
* @en Whether succeeded
|
||||
*/
|
||||
success: boolean;
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
user?: TUser;
|
||||
|
||||
/**
|
||||
* @zh 错误信息
|
||||
* @en Error message
|
||||
*/
|
||||
error?: string;
|
||||
|
||||
/**
|
||||
* @zh 错误代码
|
||||
* @en Error code
|
||||
*/
|
||||
errorCode?: AuthErrorCode;
|
||||
|
||||
/**
|
||||
* @zh 令牌
|
||||
* @en Token
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间(时间戳)
|
||||
* @en Token expiration time (timestamp)
|
||||
*/
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Provider
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证提供者接口
|
||||
* @en Authentication provider interface
|
||||
*/
|
||||
export interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||
/**
|
||||
* @zh 提供者名称
|
||||
* @en Provider name
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @zh 验证凭证
|
||||
* @en Verify credentials
|
||||
*
|
||||
* @param credentials - @zh 凭证数据 @en Credential data
|
||||
* @returns @zh 验证结果 @en Verification result
|
||||
*/
|
||||
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 刷新令牌(可选)
|
||||
* @en Refresh token (optional)
|
||||
*/
|
||||
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 撤销令牌(可选)
|
||||
* @en Revoke token (optional)
|
||||
*/
|
||||
revoke?(token: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Context
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证上下文接口
|
||||
* @en Authentication context interface
|
||||
*/
|
||||
export interface IAuthContext<TUser = unknown> {
|
||||
/**
|
||||
* @zh 是否已认证
|
||||
* @en Whether authenticated
|
||||
*/
|
||||
readonly isAuthenticated: boolean;
|
||||
|
||||
/**
|
||||
* @zh 用户信息
|
||||
* @en User information
|
||||
*/
|
||||
readonly user: TUser | null;
|
||||
|
||||
/**
|
||||
* @zh 用户 ID
|
||||
* @en User ID
|
||||
*/
|
||||
readonly userId: string | null;
|
||||
|
||||
/**
|
||||
* @zh 用户角色
|
||||
* @en User roles
|
||||
*/
|
||||
readonly roles: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* @zh 认证时间
|
||||
* @en Authentication timestamp
|
||||
*/
|
||||
readonly authenticatedAt: number | null;
|
||||
|
||||
/**
|
||||
* @zh 令牌过期时间
|
||||
* @en Token expiration time
|
||||
*/
|
||||
readonly expiresAt: number | null;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有指定角色
|
||||
* @en Check if has specified role
|
||||
*/
|
||||
hasRole(role: string): boolean;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有任一指定角色
|
||||
* @en Check if has any of specified roles
|
||||
*/
|
||||
hasAnyRole(roles: string[]): boolean;
|
||||
|
||||
/**
|
||||
* @zh 检查是否有所有指定角色
|
||||
* @en Check if has all specified roles
|
||||
*/
|
||||
hasAllRoles(roles: string[]): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Connection
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 认证连接数据
|
||||
* @en Authentication connection data
|
||||
*/
|
||||
export interface AuthConnectionData<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证上下文
|
||||
* @en Authentication context
|
||||
*/
|
||||
auth: IAuthContext<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的连接
|
||||
* @en Connection with authentication
|
||||
*/
|
||||
export interface AuthConnection<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends ServerConnection<TData> {
|
||||
/**
|
||||
* @zh 认证上下文(快捷访问)
|
||||
* @en Authentication context (shortcut)
|
||||
*/
|
||||
readonly auth: IAuthContext<TUser>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的 API 上下文
|
||||
* @en API context with authentication
|
||||
*/
|
||||
export interface AuthApiContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends ApiContext<TData> {
|
||||
/**
|
||||
* @zh 当前连接(带认证)
|
||||
* @en Current connection (with auth)
|
||||
*/
|
||||
conn: AuthConnection<TUser, TData>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带认证的消息上下文
|
||||
* @en Message context with authentication
|
||||
*/
|
||||
export interface AuthMsgContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||
extends MsgContext<TData> {
|
||||
/**
|
||||
* @zh 当前连接(带认证)
|
||||
* @en Current connection (with auth)
|
||||
*/
|
||||
conn: AuthConnection<TUser, TData>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Server Config
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 连接请求信息
|
||||
* @en Connection request info
|
||||
*/
|
||||
export interface ConnectionRequest {
|
||||
/**
|
||||
* @zh 请求 URL
|
||||
* @en Request URL
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* @zh 请求头
|
||||
* @en Request headers
|
||||
*/
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
|
||||
/**
|
||||
* @zh 客户端 IP
|
||||
* @en Client IP
|
||||
*/
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 认证服务器配置
|
||||
* @en Authentication server configuration
|
||||
*/
|
||||
export interface AuthServerConfig<TUser = unknown> {
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication provider
|
||||
*/
|
||||
provider: IAuthProvider<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 从连接请求提取凭证
|
||||
* @en Extract credentials from connection request
|
||||
*/
|
||||
extractCredentials?: (req: ConnectionRequest) => unknown | Promise<unknown>;
|
||||
|
||||
/**
|
||||
* @zh 连接时自动认证
|
||||
* @en Auto authenticate on connection
|
||||
* @defaultValue true
|
||||
*/
|
||||
autoAuthOnConnect?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 未认证连接的宽限期(毫秒)
|
||||
* @en Grace period for unauthenticated connections (ms)
|
||||
* @defaultValue 30000
|
||||
*/
|
||||
authGracePeriod?: number;
|
||||
|
||||
/**
|
||||
* @zh 认证失败是否断开连接
|
||||
* @en Disconnect on auth failure
|
||||
* @defaultValue false
|
||||
*/
|
||||
disconnectOnAuthFailure?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 认证成功回调
|
||||
* @en Authentication success callback
|
||||
*/
|
||||
onAuthSuccess?: (conn: ServerConnection, user: TUser) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* @zh 认证失败回调
|
||||
* @en Authentication failure callback
|
||||
*/
|
||||
onAuthFailure?: (conn: ServerConnection, error: AuthResult<TUser>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Game Server
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 带认证的游戏服务器
|
||||
* @en Game server with authentication
|
||||
*/
|
||||
export interface AuthGameServer<TUser = unknown> extends GameServer {
|
||||
/**
|
||||
* @zh 认证提供者
|
||||
* @en Authentication provider
|
||||
*/
|
||||
readonly authProvider: IAuthProvider<TUser>;
|
||||
|
||||
/**
|
||||
* @zh 手动认证连接
|
||||
* @en Manually authenticate connection
|
||||
*/
|
||||
authenticate(
|
||||
conn: ServerConnection,
|
||||
credentials: unknown
|
||||
): Promise<AuthResult<TUser>>;
|
||||
|
||||
/**
|
||||
* @zh 获取连接的认证上下文
|
||||
* @en Get auth context for connection
|
||||
*/
|
||||
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Room Config
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh 带认证的房间配置
|
||||
* @en Auth room configuration
|
||||
*/
|
||||
export interface AuthRoomConfig {
|
||||
/**
|
||||
* @zh 是否要求认证才能加入
|
||||
* @en Require authentication to join
|
||||
* @defaultValue true
|
||||
*/
|
||||
requireAuth?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 允许的角色(空数组表示任意角色)
|
||||
* @en Allowed roles (empty array means any role)
|
||||
*/
|
||||
allowedRoles?: string[];
|
||||
|
||||
/**
|
||||
* @zh 角色检查模式
|
||||
* @en Role check mode
|
||||
* @defaultValue 'any'
|
||||
*/
|
||||
roleCheckMode?: 'any' | 'all';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Decorator Options
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @zh requireAuth 装饰器选项
|
||||
* @en requireAuth decorator options
|
||||
*/
|
||||
export interface RequireAuthOptions {
|
||||
/**
|
||||
* @zh 认证失败时的错误消息
|
||||
* @en Error message on auth failure
|
||||
*/
|
||||
errorMessage?: string;
|
||||
|
||||
/**
|
||||
* @zh 是否允许访客
|
||||
* @en Allow guest access
|
||||
*/
|
||||
allowGuest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh requireRole 装饰器选项
|
||||
* @en requireRole decorator options
|
||||
*/
|
||||
export interface RequireRoleOptions extends RequireAuthOptions {
|
||||
/**
|
||||
* @zh 角色检查模式
|
||||
* @en Role check mode
|
||||
* @defaultValue 'any'
|
||||
*/
|
||||
mode?: 'any' | 'all';
|
||||
}
|
||||
Reference in New Issue
Block a user