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:
YHH
2025-12-29 16:10:09 +08:00
committed by GitHub
parent 1cfa64aa0f
commit 61a13baca2
26 changed files with 4404 additions and 4 deletions

View 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;
}
}