Compare commits
4 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf868a769 | ||
|
|
afdeb00b4d | ||
|
|
764ce67742 | ||
|
|
61a13baca2 |
20
README.md
20
README.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **Timer** | Timer and cooldown systems | No |
|
||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||
| **Network** | Client/server networking with TSRPC | No |
|
||||
| **Procgen** | Procedural generation (noise, random, sampling) | No |
|
||||
| **RPC** | High-performance RPC communication framework | No |
|
||||
| **Server** | Game server framework with rooms, auth, rate limiting | No |
|
||||
| **Network** | Client networking with prediction, AOI, delta compression | No |
|
||||
| **Transaction** | Game transaction system with Redis/Memory storage | No |
|
||||
| **World Streaming** | Open world chunk loading and streaming | No |
|
||||
|
||||
> All framework modules can be used standalone with any rendering engine.
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
|
||||
npm install @esengine/timer # Timers & cooldowns
|
||||
npm install @esengine/spatial # Spatial indexing
|
||||
npm install @esengine/pathfinding # Pathfinding
|
||||
npm install @esengine/network # Networking
|
||||
npm install @esengine/procgen # Procedural generation
|
||||
npm install @esengine/rpc # RPC framework
|
||||
npm install @esengine/server # Game server
|
||||
npm install @esengine/network # Client networking
|
||||
npm install @esengine/transaction # Transaction system
|
||||
npm install @esengine/world-streaming # World streaming
|
||||
```
|
||||
|
||||
### ESEngine Runtime (Optional)
|
||||
@@ -235,7 +245,11 @@ esengine/
|
||||
│ │ ├── spatial/ # Spatial queries
|
||||
│ │ ├── pathfinding/ # Pathfinding
|
||||
│ │ ├── procgen/ # Procedural generation
|
||||
│ │ └── network/ # Networking
|
||||
│ │ ├── rpc/ # RPC framework
|
||||
│ │ ├── server/ # Game server
|
||||
│ │ ├── network/ # Client networking
|
||||
│ │ ├── transaction/ # Transaction system
|
||||
│ │ └── world-streaming/ # World streaming
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine runtime
|
||||
│ ├── rendering/ # Rendering modules
|
||||
|
||||
20
README_CN.md
20
README_CN.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
||||
| **定时器** | 定时器和冷却系统 | 否 |
|
||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
||||
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
|
||||
| **RPC** | 高性能 RPC 通信框架 | 否 |
|
||||
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
|
||||
| **网络** | 客户端网络,支持预测、AOI、增量压缩 | 否 |
|
||||
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
|
||||
| **世界流送** | 开放世界分块加载和流送 | 否 |
|
||||
|
||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||
|
||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
|
||||
npm install @esengine/timer # 定时器和冷却
|
||||
npm install @esengine/spatial # 空间索引
|
||||
npm install @esengine/pathfinding # 寻路
|
||||
npm install @esengine/network # 网络
|
||||
npm install @esengine/procgen # 程序化生成
|
||||
npm install @esengine/rpc # RPC 框架
|
||||
npm install @esengine/server # 游戏服务器
|
||||
npm install @esengine/network # 客户端网络
|
||||
npm install @esengine/transaction # 事务系统
|
||||
npm install @esengine/world-streaming # 世界流送
|
||||
```
|
||||
|
||||
### ESEngine 运行时(可选)
|
||||
@@ -235,7 +245,11 @@ esengine/
|
||||
│ │ ├── spatial/ # 空间查询
|
||||
│ │ ├── pathfinding/ # 寻路
|
||||
│ │ ├── procgen/ # 程序化生成
|
||||
│ │ └── network/ # 网络
|
||||
│ │ ├── rpc/ # RPC 框架
|
||||
│ │ ├── server/ # 游戏服务器
|
||||
│ │ ├── network/ # 客户端网络
|
||||
│ │ ├── transaction/ # 事务系统
|
||||
│ │ └── world-streaming/ # 世界流送
|
||||
│ │
|
||||
│ ├── engine/ # ESEngine 运行时
|
||||
│ ├── rendering/ # 渲染模块
|
||||
|
||||
@@ -267,6 +267,8 @@ export default defineConfig({
|
||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||
|
||||
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
title: "Authentication"
|
||||
description: "Add authentication to your game server with JWT and Session providers"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
|
||||
|
||||
## Installation
|
||||
|
||||
Authentication is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> Note: `jsonwebtoken` is an optional peer dependency, required only for JWT authentication.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// Create JWT provider
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 hour
|
||||
})
|
||||
|
||||
// Wrap server with authentication
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// Define authenticated room
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Auth Providers
|
||||
|
||||
### JWT Provider
|
||||
|
||||
Use JSON Web Tokens for stateless authentication:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// Required: secret key
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// Optional: algorithm (default: HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// Optional: expiration in seconds (default: 3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// Optional: issuer for validation
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// Optional: audience for validation
|
||||
audience: 'my-game-client',
|
||||
|
||||
// Optional: custom user extraction
|
||||
getUser: async (payload) => {
|
||||
// Fetch user from database
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// Sign a token (for login endpoints)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// Decode without verification (for debugging)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Session Provider
|
||||
|
||||
Use server-side sessions for stateful authentication:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// Custom storage implementation
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 hours in ms
|
||||
|
||||
// Optional: validate user on each request
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// Create session (for login endpoints)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// Revoke session (for logout)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## Server Auth Mixin
|
||||
|
||||
The `withAuth` function wraps your server to add authentication:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// Required: auth provider
|
||||
provider: jwtProvider,
|
||||
|
||||
// Required: extract credentials from request
|
||||
extractCredentials: (req) => {
|
||||
// From query string
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// Or from headers
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// Optional: handle auth failure
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`Auth failed: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Accessing Auth Context
|
||||
|
||||
After authentication, the auth context is available on connections:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`User ${auth.userId} connected`)
|
||||
console.log(`Roles: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Room Auth Mixin
|
||||
|
||||
The `withRoomAuth` function adds authentication checks to rooms:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
// Require authentication to join
|
||||
requireAuth: true,
|
||||
|
||||
// Optional: require specific roles
|
||||
allowedRoles: ['player', 'premium'],
|
||||
|
||||
// Optional: role check mode ('any' or 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player has .auth and .user properties
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined`)
|
||||
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer Interface
|
||||
|
||||
Players in auth rooms have additional properties:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// Full auth context
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// User info (shortcut for auth.user)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### Room Auth Helpers
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// Get player by user ID
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// Get all players with a role
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// Get player with auth info
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Decorators
|
||||
|
||||
### @requireAuth
|
||||
|
||||
Mark message handlers as requiring authentication:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
Require specific roles for message handlers:
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Auth Context API
|
||||
|
||||
The auth context provides various methods for checking authentication state:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// Authentication state
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// Role checking
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
The `AuthContext` class (implementation) also provides:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// Set authentication from result
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// Clear authentication state
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use the mock auth provider for unit tests:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// Create mock provider with preset users
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // Create users for unknown tokens
|
||||
})
|
||||
|
||||
// Use in tests
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// Verify with user ID as token
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// Add/remove users dynamically
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// Revoke tokens
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// Reset to initial state
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Auth errors include error codes for programmatic handling:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||
| 'EXPIRED_TOKEN' // Token has expired
|
||||
| 'USER_NOT_FOUND' // User lookup failed
|
||||
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||
| 'RATE_LIMITED' // Too many requests
|
||||
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||
|
||||
// In your auth failure handler
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a complete example with JWT authentication:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// Types
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT Provider
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// Create authenticated server
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Game Room with auth
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('Game room created')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} joined!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// Handle movement
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, 'Kicked by admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||
|
||||
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||
|
||||
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||
|
||||
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||
|
||||
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||
|
||||
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||
|
||||
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "Rate Limiting"
|
||||
description: "Protect your game server from abuse with configurable rate limiting"
|
||||
---
|
||||
|
||||
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
|
||||
|
||||
## Installation
|
||||
|
||||
Rate limiting is included in the server package:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// Protected by rate limit (10 msg/s default)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// Stricter limit for trading
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// No rate limit for heartbeat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Strategies
|
||||
|
||||
### Token Bucket (Default)
|
||||
|
||||
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // Refill rate
|
||||
burstSize: 20, // Bucket capacity
|
||||
}) { }
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
```
|
||||
Config: rate=10/s, burstSize=20
|
||||
|
||||
[0s] Bucket full: 20 tokens
|
||||
[0s] 15 messages → allowed, 5 remaining
|
||||
[0.5s] Refill 5 tokens → 10 tokens
|
||||
[0.5s] 8 messages → allowed, 2 remaining
|
||||
[0.6s] Refill 1 token → 3 tokens
|
||||
[0.6s] 5 messages → 3 allowed, 2 rejected
|
||||
```
|
||||
|
||||
**Best for:** Most general use cases, balances burst tolerance with protection.
|
||||
|
||||
### Sliding Window
|
||||
|
||||
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** When you need precise rate limiting without burst tolerance.
|
||||
|
||||
### Fixed Window
|
||||
|
||||
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**Best for:** Simple scenarios where boundary burst is acceptable.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Room Configuration
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// Messages allowed per second (default: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// Burst capacity / bucket size (default: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Callback when rate limited
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect on rate limit (default: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// Disconnect after N consecutive limits (0 = never)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// Custom key function (default: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// Cleanup interval in ms (default: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### Per-Message Configuration
|
||||
|
||||
Use decorators to configure rate limits for specific messages:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// Custom rate limit for this message
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// This message costs 5 tokens
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// Exempt from rate limiting
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// Alternative: specify message type explicitly
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## Combining with Authentication
|
||||
|
||||
Rate limiting works seamlessly with the authentication system:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// Apply both mixins
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} joined with rate limit protection`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit Result
|
||||
|
||||
When a message is rate limited, the callback receives a result object:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// Whether the request was allowed
|
||||
allowed: boolean
|
||||
|
||||
// Remaining quota
|
||||
remaining: number
|
||||
|
||||
// When the quota resets (timestamp)
|
||||
resetAt: number
|
||||
|
||||
// How long to wait before retrying (ms)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing Rate Limit Context
|
||||
|
||||
You can access the rate limit context for any player:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// Check without consuming
|
||||
const status = context?.check()
|
||||
console.log(`Remaining: ${status?.remaining}`)
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Or use the standalone function
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## Custom Strategies
|
||||
|
||||
You can use the strategies directly for custom implementations:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// Create strategy directly
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // tokens per second
|
||||
capacity: 20, // max tokens
|
||||
})
|
||||
|
||||
// Check and consume
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// Process message
|
||||
} else {
|
||||
// Rate limited, wait result.retryAfter ms
|
||||
}
|
||||
|
||||
// Check without consuming
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// Reset a key
|
||||
strategy.reset('player-123')
|
||||
|
||||
// Cleanup expired records
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## Rate Limit Context
|
||||
|
||||
The `RateLimitContext` class manages rate limiting for a single player:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// Check without consuming
|
||||
context.check()
|
||||
|
||||
// Consume quota
|
||||
context.consume()
|
||||
|
||||
// Consume with cost
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// Consume for specific message type
|
||||
context.consume('Trade')
|
||||
|
||||
// Set per-message strategy
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// Reset
|
||||
context.reset()
|
||||
|
||||
// Get consecutive limit count
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## Room Lifecycle Hook
|
||||
|
||||
You can override the `onRateLimited` hook for custom handling:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// Log the event
|
||||
console.log(`Player ${player.id} rate limited on ${messageType}`)
|
||||
|
||||
// Send custom error
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `Slow down! Try again in ${result.retryAfter}ms`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Start with token bucket**: It's the most flexible algorithm for games.
|
||||
|
||||
2. **Set appropriate limits**: Consider your game's mechanics:
|
||||
- Movement messages: Higher limits (20-60/s)
|
||||
- Chat messages: Lower limits (1-5/s)
|
||||
- Trade/purchase: Very low limits (0.5-1/s)
|
||||
|
||||
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // Allow 3s worth of burst
|
||||
```
|
||||
|
||||
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **Combine with auth**: Rate limit by user ID for authenticated users:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **Monitor and adjust**: Log rate limit events to tune your limits:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: 'Too many requests',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// Combine auth and rate limit
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// Use user ID for rate limiting
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// Handle rate limits
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// Disconnect after 20 consecutive rate limits
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('Room created with auth + rate limit protection')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// High-frequency movement (default rate limit)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// Low-frequency trading (strict limit)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// Process trade...
|
||||
}
|
||||
|
||||
// Chat with moderate limit
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// System messages - no limit
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// Custom rate limit handling
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
506
docs/src/content/docs/modules/network/auth.md
Normal file
506
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
||||
---
|
||||
title: "认证系统"
|
||||
description: "使用 JWT 和 Session 提供者为游戏服务器添加认证功能"
|
||||
---
|
||||
|
||||
`@esengine/server` 包内置了可插拔的认证系统,支持 JWT、会话认证和自定义提供者。
|
||||
|
||||
## 安装
|
||||
|
||||
认证功能已包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server jsonwebtoken
|
||||
```
|
||||
|
||||
> 注意:`jsonwebtoken` 是可选的 peer dependency,仅在使用 JWT 认证时需要。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### JWT 认证
|
||||
|
||||
```typescript
|
||||
import { createServer } from '@esengine/server'
|
||||
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||
|
||||
// 创建 JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600, // 1 小时
|
||||
})
|
||||
|
||||
// 用认证包装服务器
|
||||
const server = withAuth(await createServer({ port: 3000 }), {
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
const url = new URL(req.url ?? '', 'http://localhost')
|
||||
return url.searchParams.get('token')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义需要认证的房间
|
||||
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||
onJoin(player) {
|
||||
console.log(`${player.user?.name} 加入了游戏!`)
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 认证提供者
|
||||
|
||||
### JWT 提供者
|
||||
|
||||
使用 JSON Web Tokens 实现无状态认证:
|
||||
|
||||
```typescript
|
||||
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||
|
||||
const jwtProvider = createJwtAuthProvider({
|
||||
// 必填:密钥
|
||||
secret: 'your-secret-key',
|
||||
|
||||
// 可选:算法(默认:HS256)
|
||||
algorithm: 'HS256',
|
||||
|
||||
// 可选:过期时间(秒,默认:3600)
|
||||
expiresIn: 3600,
|
||||
|
||||
// 可选:签发者(用于验证)
|
||||
issuer: 'my-game-server',
|
||||
|
||||
// 可选:受众(用于验证)
|
||||
audience: 'my-game-client',
|
||||
|
||||
// 可选:自定义用户提取
|
||||
getUser: async (payload) => {
|
||||
// 从数据库获取用户
|
||||
return await db.users.findById(payload.sub)
|
||||
},
|
||||
})
|
||||
|
||||
// 签发令牌(用于登录接口)
|
||||
const token = jwtProvider.sign({
|
||||
sub: user.id,
|
||||
name: user.name,
|
||||
roles: ['player'],
|
||||
})
|
||||
|
||||
// 解码但不验证(用于调试)
|
||||
const payload = jwtProvider.decode(token)
|
||||
```
|
||||
|
||||
### Session 提供者
|
||||
|
||||
使用服务端会话实现有状态认证:
|
||||
|
||||
```typescript
|
||||
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||
|
||||
// 自定义存储实现
|
||||
const storage: ISessionStorage = {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return await redis.get(key)
|
||||
},
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await redis.set(key, value)
|
||||
},
|
||||
async delete(key: string): Promise<boolean> {
|
||||
return await redis.del(key) > 0
|
||||
},
|
||||
}
|
||||
|
||||
const sessionProvider = createSessionAuthProvider({
|
||||
storage,
|
||||
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||
|
||||
// 可选:每次请求时验证用户
|
||||
validateUser: (user) => !user.banned,
|
||||
})
|
||||
|
||||
// 创建会话(用于登录接口)
|
||||
const sessionId = await sessionProvider.createSession(user, {
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
})
|
||||
|
||||
// 撤销会话(用于登出)
|
||||
await sessionProvider.revoke(sessionId)
|
||||
```
|
||||
|
||||
## 服务器认证 Mixin
|
||||
|
||||
`withAuth` 函数用于包装服务器添加认证功能:
|
||||
|
||||
```typescript
|
||||
import { withAuth } from '@esengine/server/auth'
|
||||
|
||||
const server = withAuth(baseServer, {
|
||||
// 必填:认证提供者
|
||||
provider: jwtProvider,
|
||||
|
||||
// 必填:从请求中提取凭证
|
||||
extractCredentials: (req) => {
|
||||
// 从查询字符串获取
|
||||
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||
|
||||
// 或从请求头获取
|
||||
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||
},
|
||||
|
||||
// 可选:处理认证失败
|
||||
onAuthFailed: (conn, error) => {
|
||||
console.log(`认证失败: ${error}`)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 访问认证上下文
|
||||
|
||||
认证后,可以从连接获取认证上下文:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from '@esengine/server/auth'
|
||||
|
||||
server.onConnect = (conn) => {
|
||||
const auth = getAuthContext(conn)
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
console.log(`用户 ${auth.userId} 已连接`)
|
||||
console.log(`角色: ${auth.roles}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 房间认证 Mixin
|
||||
|
||||
`withRoomAuth` 函数为房间添加认证检查:
|
||||
|
||||
```typescript
|
||||
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'],
|
||||
|
||||
// 可选:角色检查模式('any' 或 'all')
|
||||
roleCheckMode: 'any',
|
||||
}) {
|
||||
// player 拥有 .auth 和 .user 属性
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了`)
|
||||
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||
}
|
||||
|
||||
// 可选:自定义认证验证
|
||||
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||
// 额外的验证逻辑
|
||||
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 ?? '访客',
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPlayer 接口
|
||||
|
||||
认证房间中的玩家拥有额外属性:
|
||||
|
||||
```typescript
|
||||
interface AuthPlayer<TUser> extends Player {
|
||||
// 完整认证上下文
|
||||
readonly auth: IAuthContext<TUser>
|
||||
|
||||
// 用户信息(auth.user 的快捷方式)
|
||||
readonly user: TUser | null
|
||||
}
|
||||
```
|
||||
|
||||
### 房间认证辅助方法
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRoomAuth<User>(Room) {
|
||||
someMethod() {
|
||||
// 通过用户 ID 获取玩家
|
||||
const player = this.getPlayerByUserId('user-123')
|
||||
|
||||
// 获取拥有特定角色的所有玩家
|
||||
const admins = this.getPlayersByRole('admin')
|
||||
|
||||
// 获取带认证信息的玩家
|
||||
const authPlayer = this.getAuthPlayer(playerId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证装饰器
|
||||
|
||||
### @requireAuth
|
||||
|
||||
标记消息处理器需要认证:
|
||||
|
||||
```typescript
|
||||
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||
|
||||
class GameRoom extends withRoomAuth(Room) {
|
||||
@requireAuth()
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||
// 只有已认证玩家才能交易
|
||||
}
|
||||
|
||||
@requireAuth({ allowGuest: true })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: ChatData, player: AuthPlayer) {
|
||||
// 访客也可以聊天
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### @requireRole
|
||||
|
||||
要求特定角色才能访问消息处理器:
|
||||
|
||||
```typescript
|
||||
class AdminRoom extends withRoomAuth(Room) {
|
||||
@requireRole('admin')
|
||||
@onMessage('Ban')
|
||||
handleBan(data: BanData, player: AuthPlayer) {
|
||||
// 只有管理员才能封禁
|
||||
}
|
||||
|
||||
@requireRole(['moderator', 'admin'])
|
||||
@onMessage('Mute')
|
||||
handleMute(data: MuteData, player: AuthPlayer) {
|
||||
// 版主或管理员可以禁言
|
||||
}
|
||||
|
||||
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||
@onMessage('SpecialFeature')
|
||||
handleSpecial(data: any, player: AuthPlayer) {
|
||||
// 需要同时拥有 verified 和 premium 角色
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 认证上下文 API
|
||||
|
||||
认证上下文提供多种检查认证状态的方法:
|
||||
|
||||
```typescript
|
||||
interface IAuthContext<TUser> {
|
||||
// 认证状态
|
||||
readonly isAuthenticated: boolean
|
||||
readonly user: TUser | null
|
||||
readonly userId: string | null
|
||||
readonly roles: ReadonlyArray<string>
|
||||
readonly authenticatedAt: number | null
|
||||
readonly expiresAt: number | null
|
||||
|
||||
// 角色检查
|
||||
hasRole(role: string): boolean
|
||||
hasAnyRole(roles: string[]): boolean
|
||||
hasAllRoles(roles: string[]): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`AuthContext` 类(实现类)还提供:
|
||||
|
||||
```typescript
|
||||
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||
// 从认证结果设置认证状态
|
||||
setAuthenticated(result: AuthResult<TUser>): void
|
||||
|
||||
// 清除认证状态
|
||||
clear(): void
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
使用模拟认证提供者进行单元测试:
|
||||
|
||||
```typescript
|
||||
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||
|
||||
// 创建带预设用户的模拟提供者
|
||||
const mockProvider = createMockAuthProvider({
|
||||
users: [
|
||||
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||
],
|
||||
autoCreate: true, // 为未知令牌创建用户
|
||||
})
|
||||
|
||||
// 在测试中使用
|
||||
const server = withAuth(testServer, {
|
||||
provider: mockProvider,
|
||||
extractCredentials: (req) => req.headers['x-token'],
|
||||
})
|
||||
|
||||
// 使用用户 ID 作为令牌进行验证
|
||||
const result = await mockProvider.verify('1')
|
||||
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||
|
||||
// 动态添加/移除用户
|
||||
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||
mockProvider.removeUser('3')
|
||||
|
||||
// 撤销令牌
|
||||
await mockProvider.revoke('1')
|
||||
|
||||
// 重置到初始状态
|
||||
mockProvider.clear()
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
认证错误包含错误码用于程序化处理:
|
||||
|
||||
```typescript
|
||||
type AuthErrorCode =
|
||||
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||
| 'USER_NOT_FOUND' // 用户查找失败
|
||||
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||
| 'RATE_LIMITED' // 请求过于频繁
|
||||
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||
|
||||
// 在认证失败处理器中
|
||||
const server = withAuth(baseServer, {
|
||||
provider: jwtProvider,
|
||||
extractCredentials,
|
||||
onAuthFailed: (conn, error) => {
|
||||
switch (error.errorCode) {
|
||||
case 'EXPIRED_TOKEN':
|
||||
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||
break
|
||||
case 'INVALID_TOKEN':
|
||||
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||
break
|
||||
default:
|
||||
conn.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
以下是使用 JWT 认证的完整示例:
|
||||
|
||||
```typescript
|
||||
// server.ts
|
||||
import { createServer } from '@esengine/server'
|
||||
import {
|
||||
withAuth,
|
||||
withRoomAuth,
|
||||
createJwtAuthProvider,
|
||||
requireAuth,
|
||||
requireRole,
|
||||
type AuthPlayer,
|
||||
} from '@esengine/server/auth'
|
||||
|
||||
// 类型定义
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// JWT 提供者
|
||||
const jwtProvider = createJwtAuthProvider<User>({
|
||||
secret: process.env.JWT_SECRET!,
|
||||
expiresIn: 3600,
|
||||
getUser: async (payload) => ({
|
||||
id: payload.sub as string,
|
||||
name: payload.name as string,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
}),
|
||||
})
|
||||
|
||||
// 创建带认证的服务器
|
||||
const server = withAuth(
|
||||
await createServer({ port: 3000 }),
|
||||
{
|
||||
provider: jwtProvider,
|
||||
extractCredentials: (req) => {
|
||||
return new URL(req.url ?? '', 'http://localhost')
|
||||
.searchParams.get('token')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 带认证的游戏房间
|
||||
class GameRoom extends withRoomAuth<User>(Room, {
|
||||
requireAuth: true,
|
||||
allowedRoles: ['player'],
|
||||
}) {
|
||||
onCreate() {
|
||||
console.log('游戏房间已创建')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
console.log(`${player.user?.name} 加入了!`)
|
||||
this.broadcast('PlayerJoined', {
|
||||
id: player.id,
|
||||
name: player.user?.name,
|
||||
})
|
||||
}
|
||||
|
||||
@requireAuth()
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
// 处理移动
|
||||
}
|
||||
|
||||
@requireRole('admin')
|
||||
@onMessage('Kick')
|
||||
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||
const target = this.getPlayer(data.playerId)
|
||||
if (target) {
|
||||
this.kick(target, '被管理员踢出')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server.define('game', GameRoom)
|
||||
await server.start()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||
|
||||
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||
|
||||
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||
|
||||
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||
|
||||
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||
|
||||
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||
|
||||
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: "速率限制"
|
||||
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
|
||||
---
|
||||
|
||||
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
|
||||
|
||||
## 安装
|
||||
|
||||
速率限制包含在 server 包中:
|
||||
|
||||
```bash
|
||||
npm install @esengine/server
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage } from '@esengine/server'
|
||||
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20,
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
}) {
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: Player) {
|
||||
// 受速率限制保护(默认 10 msg/s)
|
||||
}
|
||||
|
||||
@rateLimit({ messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) {
|
||||
// 交易使用更严格的限制
|
||||
}
|
||||
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
// 心跳不限制
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制策略
|
||||
|
||||
### 令牌桶(默认)
|
||||
|
||||
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'token-bucket',
|
||||
messagesPerSecond: 10, // 补充速率
|
||||
burstSize: 20, // 桶容量
|
||||
}) { }
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
```
|
||||
配置: rate=10/s, burstSize=20
|
||||
|
||||
[0s] 桶满: 20 令牌
|
||||
[0s] 收到 15 条消息 → 允许,剩余 5
|
||||
[0.5s] 补充 5 令牌 → 10 令牌
|
||||
[0.5s] 收到 8 条消息 → 允许,剩余 2
|
||||
[0.6s] 补充 1 令牌 → 3 令牌
|
||||
[0.6s] 收到 5 条消息 → 允许 3,拒绝 2
|
||||
```
|
||||
|
||||
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
|
||||
|
||||
### 滑动窗口
|
||||
|
||||
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'sliding-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 需要精确限流且不需要突发容忍的场景。
|
||||
|
||||
### 固定窗口
|
||||
|
||||
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
strategy: 'fixed-window',
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 10,
|
||||
}) { }
|
||||
```
|
||||
|
||||
**最适合:** 简单场景,可接受边界突发。
|
||||
|
||||
## 配置
|
||||
|
||||
### 房间配置
|
||||
|
||||
```typescript
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room, {
|
||||
// 每秒允许的消息数(默认: 10)
|
||||
messagesPerSecond: 10,
|
||||
|
||||
// 突发容量 / 桶大小(默认: 20)
|
||||
burstSize: 20,
|
||||
|
||||
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 被限流时的回调
|
||||
onLimited: (player, messageType, result) => {
|
||||
player.send('RateLimited', {
|
||||
type: messageType,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 限流时断开连接(默认: false)
|
||||
disconnectOnLimit: false,
|
||||
|
||||
// 连续 N 次限流后断开(0 = 永不)
|
||||
maxConsecutiveLimits: 10,
|
||||
|
||||
// 自定义键函数(默认: player.id)
|
||||
getKey: (player) => player.id,
|
||||
|
||||
// 清理间隔(毫秒,默认: 60000)
|
||||
cleanupInterval: 60000,
|
||||
}) { }
|
||||
```
|
||||
|
||||
### 单消息配置
|
||||
|
||||
使用装饰器为特定消息配置速率限制:
|
||||
|
||||
```typescript
|
||||
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
// 此消息使用自定义速率限制
|
||||
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: Player) { }
|
||||
|
||||
// 此消息消耗 5 个令牌
|
||||
@rateLimit({ cost: 5 })
|
||||
@onMessage('ExpensiveAction')
|
||||
handleExpensive(data: any, player: Player) { }
|
||||
|
||||
// 豁免速率限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) { }
|
||||
|
||||
// 替代方案:显式指定消息类型
|
||||
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||
@onMessage('SpecialAction')
|
||||
handleSpecial(data: any, player: Player) { }
|
||||
}
|
||||
```
|
||||
|
||||
## 与认证系统组合
|
||||
|
||||
速率限制可与认证系统无缝配合:
|
||||
|
||||
```typescript
|
||||
import { withRoomAuth } from '@esengine/server/auth'
|
||||
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||
|
||||
// 同时应用两个 mixin
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth(Room, { requireAuth: true }),
|
||||
{ messagesPerSecond: 10 }
|
||||
) {
|
||||
onJoin(player: AuthPlayer) {
|
||||
console.log(`${player.user?.name} 已加入,受速率限制保护`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 速率限制结果
|
||||
|
||||
当消息被限流时,回调会收到结果对象:
|
||||
|
||||
```typescript
|
||||
interface RateLimitResult {
|
||||
// 是否允许请求
|
||||
allowed: boolean
|
||||
|
||||
// 剩余配额
|
||||
remaining: number
|
||||
|
||||
// 配额重置时间(时间戳)
|
||||
resetAt: number
|
||||
|
||||
// 重试等待时间(毫秒)
|
||||
retryAfter?: number
|
||||
}
|
||||
```
|
||||
|
||||
## 访问速率限制上下文
|
||||
|
||||
你可以访问任何玩家的速率限制上下文:
|
||||
|
||||
```typescript
|
||||
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
someMethod(player: Player) {
|
||||
const context = this.getRateLimitContext(player)
|
||||
|
||||
// 检查但不消费
|
||||
const status = context?.check()
|
||||
console.log(`剩余: ${status?.remaining}`)
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 或使用独立函数
|
||||
const context = getPlayerRateLimitContext(player)
|
||||
```
|
||||
|
||||
## 自定义策略
|
||||
|
||||
你可以直接使用策略进行自定义实现:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createTokenBucketStrategy,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
// 直接创建策略
|
||||
const strategy = createTokenBucketStrategy({
|
||||
rate: 10, // 每秒令牌数
|
||||
capacity: 20, // 最大令牌数
|
||||
})
|
||||
|
||||
// 检查并消费
|
||||
const result = strategy.consume('player-123')
|
||||
if (result.allowed) {
|
||||
// 处理消息
|
||||
} else {
|
||||
// 被限流,等待 result.retryAfter 毫秒
|
||||
}
|
||||
|
||||
// 检查但不消费
|
||||
const status = strategy.getStatus('player-123')
|
||||
|
||||
// 重置某个键
|
||||
strategy.reset('player-123')
|
||||
|
||||
// 清理过期记录
|
||||
strategy.cleanup()
|
||||
```
|
||||
|
||||
## 速率限制上下文
|
||||
|
||||
`RateLimitContext` 类管理单个玩家的速率限制:
|
||||
|
||||
```typescript
|
||||
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||
|
||||
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||
const context = new RateLimitContext('player-123', strategy)
|
||||
|
||||
// 检查但不消费
|
||||
context.check()
|
||||
|
||||
// 消费配额
|
||||
context.consume()
|
||||
|
||||
// 带消耗量消费
|
||||
context.consume(undefined, 5)
|
||||
|
||||
// 为特定消息类型消费
|
||||
context.consume('Trade')
|
||||
|
||||
// 设置单消息策略
|
||||
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||
|
||||
// 重置
|
||||
context.reset()
|
||||
|
||||
// 获取连续限流次数
|
||||
console.log(context.consecutiveLimitCount)
|
||||
```
|
||||
|
||||
## 房间生命周期钩子
|
||||
|
||||
你可以重写 `onRateLimited` 钩子进行自定义处理:
|
||||
|
||||
```typescript
|
||||
class GameRoom extends withRateLimit(Room) {
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
// 记录事件
|
||||
console.log(`玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
|
||||
// 发送自定义错误
|
||||
player.send('SystemMessage', {
|
||||
type: 'warning',
|
||||
message: `请慢一点!${result.retryAfter}ms 后重试`,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
|
||||
|
||||
2. **设置合适的限制**:考虑你的游戏机制:
|
||||
- 移动消息:较高限制(20-60/s)
|
||||
- 聊天消息:较低限制(1-5/s)
|
||||
- 交易/购买:非常低的限制(0.5-1/s)
|
||||
|
||||
3. **使用突发容量**:允许短暂突发以获得响应式体验:
|
||||
```typescript
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30, // 允许 3 秒的突发
|
||||
```
|
||||
|
||||
4. **豁免关键消息**:不要限制心跳或系统消息:
|
||||
```typescript
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() { }
|
||||
```
|
||||
|
||||
5. **与认证结合**:对已认证用户按用户 ID 限流:
|
||||
```typescript
|
||||
getKey: (player) => player.auth?.userId ?? player.id
|
||||
```
|
||||
|
||||
6. **监控和调整**:记录限流事件以调整限制:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
metrics.increment('rate_limit', { messageType: type })
|
||||
}
|
||||
```
|
||||
|
||||
7. **优雅降级**:发送信息性错误而不是直接断开:
|
||||
```typescript
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
message: '请求过于频繁',
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { Room, onMessage, type Player } from '@esengine/server'
|
||||
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||
import {
|
||||
withRateLimit,
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
type RateLimitResult,
|
||||
} from '@esengine/server/ratelimit'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
premium: boolean
|
||||
}
|
||||
|
||||
// 组合认证和速率限制
|
||||
class GameRoom extends withRateLimit(
|
||||
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||
{
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 30,
|
||||
strategy: 'token-bucket',
|
||||
|
||||
// 使用用户 ID 进行限流
|
||||
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||
|
||||
// 处理限流
|
||||
onLimited: (player, type, result) => {
|
||||
player.send('Error', {
|
||||
code: 'RATE_LIMITED',
|
||||
messageType: type,
|
||||
retryAfter: result.retryAfter,
|
||||
})
|
||||
},
|
||||
|
||||
// 连续 20 次限流后断开
|
||||
maxConsecutiveLimits: 20,
|
||||
}
|
||||
) {
|
||||
onCreate() {
|
||||
console.log('房间已创建,具有认证 + 速率限制保护')
|
||||
}
|
||||
|
||||
onJoin(player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||
}
|
||||
|
||||
// 高频移动(默认速率限制)
|
||||
@onMessage('Move')
|
||||
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||
}
|
||||
|
||||
// 低频交易(严格限制)
|
||||
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||
@onMessage('Trade')
|
||||
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||
// 处理交易...
|
||||
}
|
||||
|
||||
// 聊天使用中等限制
|
||||
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||
@onMessage('Chat')
|
||||
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||
this.broadcast('Chat', {
|
||||
from: player.user?.name,
|
||||
text: data.text,
|
||||
})
|
||||
}
|
||||
|
||||
// 系统消息 - 不限制
|
||||
@noRateLimit()
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat(data: any, player: Player) {
|
||||
player.send('Pong', { time: Date.now() })
|
||||
}
|
||||
|
||||
// 自定义限流处理
|
||||
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,32 @@
|
||||
# @esengine/server
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#388](https://github.com/esengine/esengine/pull/388) [`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system
|
||||
- 新增令牌桶策略 (`TokenBucketStrategy`) - 推荐用于一般场景
|
||||
- 新增滑动窗口策略 (`SlidingWindowStrategy`) - 精确跟踪
|
||||
- 新增固定窗口策略 (`FixedWindowStrategy`) - 简单高效
|
||||
- 新增房间速率限制 mixin (`withRateLimit`)
|
||||
- 新增速率限制装饰器 (`@rateLimit`, `@noRateLimit`)
|
||||
- 新增按消息类型限流装饰器 (`@rateLimitMessage`, `@noRateLimitMessage`)
|
||||
- 支持与认证系统组合使用
|
||||
- 导出路径: `@esengine/server/ratelimit`
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#386](https://github.com/esengine/esengine/pull/386) [`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔认证系统 | add pluggable authentication system
|
||||
- 新增 JWT 认证提供者 (`createJwtAuthProvider`)
|
||||
- 新增 Session 认证提供者 (`createSessionAuthProvider`)
|
||||
- 新增服务器认证 mixin (`withAuth`)
|
||||
- 新增房间认证 mixin (`withRoomAuth`)
|
||||
- 新增认证装饰器 (`@requireAuth`, `@requireRole`)
|
||||
- 新增测试工具 (`MockAuthProvider`)
|
||||
- 导出路径: `@esengine/server/auth`, `@esengine/server/auth/testing`
|
||||
|
||||
## 1.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/server",
|
||||
"version": "1.1.4",
|
||||
"version": "1.3.0",
|
||||
"description": "Game server framework for ESEngine with file-based routing",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -11,6 +11,18 @@
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./auth": {
|
||||
"import": "./dist/auth/index.js",
|
||||
"types": "./dist/auth/index.d.ts"
|
||||
},
|
||||
"./auth/testing": {
|
||||
"import": "./dist/auth/testing/index.js",
|
||||
"types": "./dist/auth/testing/index.d.ts"
|
||||
},
|
||||
"./ratelimit": {
|
||||
"import": "./dist/ratelimit/index.js",
|
||||
"types": "./dist/ratelimit/index.d.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing/index.js",
|
||||
"types": "./dist/testing/index.d.ts"
|
||||
@@ -33,11 +45,19 @@
|
||||
"@esengine/rpc": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": ">=8.0.0"
|
||||
"ws": ">=8.0.0",
|
||||
"jsonwebtoken": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jsonwebtoken": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RateLimitContext } from '../context';
|
||||
import { TokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { FixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('RateLimitContext', () => {
|
||||
let globalStrategy: TokenBucketStrategy;
|
||||
let context: RateLimitContext;
|
||||
|
||||
beforeEach(() => {
|
||||
globalStrategy = new TokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
context = new RateLimitContext('player-123', globalStrategy);
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('should check without consuming', () => {
|
||||
const result1 = context.check();
|
||||
const result2 = context.check();
|
||||
|
||||
expect(result1.remaining).toBe(result2.remaining);
|
||||
});
|
||||
|
||||
it('should use global strategy by default', () => {
|
||||
const result = context.check();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should consume from global strategy', () => {
|
||||
const result = context.consume();
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should track consecutive limits', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should reset consecutive count on success', () => {
|
||||
// Consume all 20 tokens plus some more to trigger rate limiting
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
// After consuming 25 tokens (20 capacity), 5 should be rate limited
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.reset();
|
||||
const result = context.consume();
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset global strategy', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
context.reset();
|
||||
|
||||
const status = context.check();
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
|
||||
it('should reset specific message type', () => {
|
||||
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
context.setMessageStrategy('Trade', msgStrategy);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
context.consume('Trade');
|
||||
}
|
||||
|
||||
context.reset('Trade');
|
||||
|
||||
const status = context.check('Trade');
|
||||
expect(status.remaining).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('message strategies', () => {
|
||||
it('should use message-specific strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
const result1 = context.consume('Trade');
|
||||
expect(result1.allowed).toBe(true);
|
||||
|
||||
const result2 = context.consume('Trade');
|
||||
expect(result2.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to global strategy for unknown types', () => {
|
||||
const result = context.consume('UnknownType');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should check if message strategy exists', () => {
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove message strategy', () => {
|
||||
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||
context.setMessageStrategy('Trade', tradeStrategy);
|
||||
|
||||
context.removeMessageStrategy('Trade');
|
||||
|
||||
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetConsecutiveCount', () => {
|
||||
it('should reset consecutive limit count', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
context.consume();
|
||||
}
|
||||
|
||||
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||
|
||||
context.resetConsecutiveCount();
|
||||
|
||||
expect(context.consecutiveLimitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from '../decorators/rateLimit';
|
||||
|
||||
describe('rateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
|
||||
handleTrade() {
|
||||
return 'trade';
|
||||
}
|
||||
|
||||
@rateLimitMessage('Move', { cost: 2 })
|
||||
handleMove() {
|
||||
return 'move';
|
||||
}
|
||||
|
||||
undecorated() {
|
||||
return 'undecorated';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should store rate limit metadata on target', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should store config in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||
expect(metadata?.config?.messagesPerSecond).toBe(1);
|
||||
expect(metadata?.config?.burstSize).toBe(2);
|
||||
});
|
||||
|
||||
it('should store cost in metadata', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
|
||||
expect(metadata?.config?.cost).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined for unregistered message types', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleTrade()).toBe('trade');
|
||||
expect(instance.handleMove()).toBe('move');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimitMessage decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
handleHeartbeat() {
|
||||
return 'heartbeat';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('Ping')
|
||||
handlePing() {
|
||||
return 'ping';
|
||||
}
|
||||
}
|
||||
|
||||
describe('metadata storage', () => {
|
||||
it('should mark message as exempt', () => {
|
||||
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
expect(metadata?.exempt).toBe(true);
|
||||
expect(metadata?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should store for multiple messages', () => {
|
||||
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
|
||||
|
||||
expect(heartbeatMeta?.exempt).toBe(true);
|
||||
expect(pingMeta?.exempt).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('method behavior', () => {
|
||||
it('should not alter method behavior', () => {
|
||||
const instance = new TestClass();
|
||||
expect(instance.handleHeartbeat()).toBe('heartbeat');
|
||||
expect(instance.handlePing()).toBe('ping');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined decorators', () => {
|
||||
class CombinedTestClass {
|
||||
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
|
||||
handleSlow() {
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
@noRateLimitMessage('FastAction')
|
||||
handleFast() {
|
||||
return 'fast';
|
||||
}
|
||||
|
||||
@rateLimitMessage('ExpensiveAction', { cost: 10 })
|
||||
handleExpensive() {
|
||||
return 'expensive';
|
||||
}
|
||||
}
|
||||
|
||||
it('should handle multiple different decorators', () => {
|
||||
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
|
||||
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
|
||||
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
|
||||
|
||||
expect(slowMeta?.enabled).toBe(true);
|
||||
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
|
||||
|
||||
expect(fastMeta?.exempt).toBe(true);
|
||||
expect(fastMeta?.enabled).toBe(false);
|
||||
|
||||
expect(expensiveMeta?.enabled).toBe(true);
|
||||
expect(expensiveMeta?.config?.cost).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RATE_LIMIT_METADATA_KEY', () => {
|
||||
it('should be a symbol', () => {
|
||||
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('should be used for metadata storage', () => {
|
||||
class TestClass {
|
||||
@rateLimitMessage('Test', {})
|
||||
handleTest() {}
|
||||
}
|
||||
|
||||
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||
expect(metadataMap).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof rateLimit).toBe('function');
|
||||
expect(typeof rateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept config', () => {
|
||||
class TestClass {
|
||||
@rateLimit({ messagesPerSecond: 5 })
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('noRateLimit decorator (auto-detect)', () => {
|
||||
it('should be a decorator function', () => {
|
||||
expect(typeof noRateLimit).toBe('function');
|
||||
expect(typeof noRateLimit()).toBe('function');
|
||||
});
|
||||
|
||||
it('should work as decorator', () => {
|
||||
class TestClass {
|
||||
@noRateLimit()
|
||||
someMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestClass();
|
||||
expect(instance.someMethod()).toBe('test');
|
||||
});
|
||||
});
|
||||
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Room } from '../../room/Room';
|
||||
import { Player } from '../../room/Player';
|
||||
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
|
||||
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
|
||||
import { onMessage } from '../../room/decorators';
|
||||
|
||||
describe('withRateLimit mixin', () => {
|
||||
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
|
||||
|
||||
beforeEach(() => {
|
||||
RateLimitedRoom = withRateLimit(Room, {
|
||||
messagesPerSecond: 10,
|
||||
burstSize: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should create a rate limited room class', () => {
|
||||
expect(RateLimitedRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have rateLimitStrategy property', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy selection', () => {
|
||||
it('should use token-bucket by default', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('should use sliding-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('should use fixed-window when specified', () => {
|
||||
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('should use default values', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom messagesPerSecond', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept custom burstSize', () => {
|
||||
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('should clean up on dispose', () => {
|
||||
class TestRoom extends RateLimitedRoom {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
room._init({
|
||||
id: 'test-room',
|
||||
sendFn: vi.fn(),
|
||||
broadcastFn: vi.fn(),
|
||||
disposeFn: vi.fn()
|
||||
});
|
||||
|
||||
expect(() => room.dispose()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRateLimit with auth', () => {
|
||||
it('should be composable with other mixins', () => {
|
||||
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
|
||||
onCreate() {}
|
||||
}
|
||||
|
||||
const room = new TestRoom();
|
||||
expect(room.rateLimitStrategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerRateLimitContext', () => {
|
||||
it('should return null for player without context', () => {
|
||||
const mockPlayer = {
|
||||
id: 'player-1',
|
||||
roomId: 'room-1',
|
||||
data: {},
|
||||
send: vi.fn(),
|
||||
leave: vi.fn()
|
||||
} as unknown as Player;
|
||||
|
||||
const context = getPlayerRateLimitContext(mockPlayer);
|
||||
expect(context).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorator metadata', () => {
|
||||
it('rateLimitMessage should set metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||
@onMessage('Trade')
|
||||
handleTrade() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
|
||||
it('noRateLimitMessage should set exempt metadata', () => {
|
||||
class TestRoom extends withRateLimit(Room) {
|
||||
@noRateLimitMessage('Heartbeat')
|
||||
@onMessage('Heartbeat')
|
||||
handleHeartbeat() {}
|
||||
}
|
||||
|
||||
expect(TestRoom).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
|
||||
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
|
||||
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
|
||||
|
||||
describe('TokenBucketStrategy', () => {
|
||||
let strategy: TokenBucketStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createTokenBucketStrategy({
|
||||
rate: 10,
|
||||
capacity: 20
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests when tokens available', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(19);
|
||||
});
|
||||
|
||||
it('should consume multiple tokens', () => {
|
||||
const result = strategy.consume('user-1', 5);
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(15);
|
||||
});
|
||||
|
||||
it('should deny when not enough tokens', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
expect(result.retryAfter).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should refill tokens over time', async () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle different keys independently', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result1 = strategy.consume('user-1');
|
||||
const result2 = strategy.consume('user-2');
|
||||
|
||||
expect(result1.allowed).toBe(false);
|
||||
expect(result2.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(20);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should not consume tokens', () => {
|
||||
strategy.getStatus('user-1');
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset key to full capacity', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up full buckets', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SlidingWindowStrategy', () => {
|
||||
let strategy: SlidingWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createSlidingWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow after window expires', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
expect(status.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should clear timestamps', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FixedWindowStrategy', () => {
|
||||
let strategy: FixedWindowStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = createFixedWindowStrategy({
|
||||
rate: 10,
|
||||
capacity: 10
|
||||
});
|
||||
});
|
||||
|
||||
describe('consume', () => {
|
||||
it('should allow requests within capacity', () => {
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
|
||||
it('should deny when capacity exceeded', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should reset at window boundary', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
const result = strategy.consume('user-1');
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.remaining).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should return full capacity for new key', () => {
|
||||
const status = strategy.getStatus('new-user');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset count', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
strategy.consume('user-1');
|
||||
}
|
||||
|
||||
strategy.reset('user-1');
|
||||
|
||||
const status = strategy.getStatus('user-1');
|
||||
expect(status.remaining).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old windows', async () => {
|
||||
strategy.consume('user-1');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||
|
||||
strategy.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory functions', () => {
|
||||
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
|
||||
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
|
||||
expect(strategy.name).toBe('token-bucket');
|
||||
});
|
||||
|
||||
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
|
||||
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('sliding-window');
|
||||
});
|
||||
|
||||
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
|
||||
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||
expect(strategy.name).toBe('fixed-window');
|
||||
});
|
||||
});
|
||||
146
packages/framework/server/src/ratelimit/context.ts
Normal file
146
packages/framework/server/src/ratelimit/context.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*/
|
||||
|
||||
import type {
|
||||
IRateLimitContext,
|
||||
IRateLimitStrategy,
|
||||
RateLimitResult,
|
||||
MessageRateLimitConfig
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*
|
||||
* @zh 管理单个玩家的速率限制状态,支持全局限制和按消息类型限制
|
||||
* @en Manages rate limit status for a single player, supports global and per-message-type limits
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const context = new RateLimitContext('player-123', globalStrategy);
|
||||
*
|
||||
* // Check global rate limit
|
||||
* const result = context.consume();
|
||||
*
|
||||
* // Check per-message rate limit
|
||||
* const tradeResult = context.consume('Trade', 1);
|
||||
* ```
|
||||
*/
|
||||
export class RateLimitContext implements IRateLimitContext {
|
||||
private _key: string;
|
||||
private _globalStrategy: IRateLimitStrategy;
|
||||
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
|
||||
private _consecutiveLimitCount: number = 0;
|
||||
|
||||
/**
|
||||
* @zh 创建速率限制上下文
|
||||
* @en Create rate limit context
|
||||
*
|
||||
* @param key - @zh 限流键(通常是玩家ID)@en Rate limit key (usually player ID)
|
||||
* @param globalStrategy - @zh 全局限流策略 @en Global rate limit strategy
|
||||
*/
|
||||
constructor(key: string, globalStrategy: IRateLimitStrategy) {
|
||||
this._key = key;
|
||||
this._globalStrategy = globalStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取连续被限流次数
|
||||
* @en Get consecutive limit count
|
||||
*/
|
||||
get consecutiveLimitCount(): number {
|
||||
return this._consecutiveLimitCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否允许(不消费)
|
||||
* @en Check if allowed (without consuming)
|
||||
*/
|
||||
check(messageType?: string): RateLimitResult {
|
||||
if (messageType && this._messageStrategies.has(messageType)) {
|
||||
return this._messageStrategies.get(messageType)!.getStatus(this._key);
|
||||
}
|
||||
|
||||
return this._globalStrategy.getStatus(this._key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 消费配额
|
||||
* @en Consume quota
|
||||
*/
|
||||
consume(messageType?: string, cost: number = 1): RateLimitResult {
|
||||
let result: RateLimitResult;
|
||||
|
||||
if (messageType && this._messageStrategies.has(messageType)) {
|
||||
result = this._messageStrategies.get(messageType)!.consume(this._key, cost);
|
||||
} else {
|
||||
result = this._globalStrategy.consume(this._key, cost);
|
||||
}
|
||||
|
||||
if (result.allowed) {
|
||||
this._consecutiveLimitCount = 0;
|
||||
} else {
|
||||
this._consecutiveLimitCount++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置限流状态
|
||||
* @en Reset rate limit status
|
||||
*/
|
||||
reset(messageType?: string): void {
|
||||
if (messageType) {
|
||||
if (this._messageStrategies.has(messageType)) {
|
||||
this._messageStrategies.get(messageType)!.reset(this._key);
|
||||
}
|
||||
} else {
|
||||
this._globalStrategy.reset(this._key);
|
||||
for (const strategy of this._messageStrategies.values()) {
|
||||
strategy.reset(this._key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置连续限流计数
|
||||
* @en Reset consecutive limit count
|
||||
*/
|
||||
resetConsecutiveCount(): void {
|
||||
this._consecutiveLimitCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 为特定消息类型设置独立的限流策略
|
||||
* @en Set independent rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @param strategy - @zh 限流策略 @en Rate limit strategy
|
||||
*/
|
||||
setMessageStrategy(messageType: string, strategy: IRateLimitStrategy): void {
|
||||
this._messageStrategies.set(messageType, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 移除特定消息类型的限流策略
|
||||
* @en Remove rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
*/
|
||||
removeMessageStrategy(messageType: string): void {
|
||||
this._messageStrategies.delete(messageType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 检查是否有特定消息类型的限流策略
|
||||
* @en Check if has rate limit strategy for specific message type
|
||||
*
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
*/
|
||||
hasMessageStrategy(messageType: string): boolean {
|
||||
return this._messageStrategies.has(messageType);
|
||||
}
|
||||
}
|
||||
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
13
packages/framework/server/src/ratelimit/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorators
|
||||
*/
|
||||
|
||||
export {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from './rateLimit.js';
|
||||
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal file
246
packages/framework/server/src/ratelimit/decorators/rateLimit.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorators
|
||||
*/
|
||||
|
||||
import type { MessageRateLimitConfig, RateLimitMetadata } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制元数据存储键
|
||||
* @en Rate limit metadata storage key
|
||||
*/
|
||||
export const RATE_LIMIT_METADATA_KEY = Symbol('rateLimitMetadata');
|
||||
|
||||
/**
|
||||
* @zh 获取速率限制元数据
|
||||
* @en Get rate limit metadata
|
||||
*
|
||||
* @param target - @zh 目标对象 @en Target object
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @returns @zh 元数据 @en Metadata
|
||||
*/
|
||||
export function getRateLimitMetadata(target: any, messageType: string): RateLimitMetadata | undefined {
|
||||
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata> | undefined;
|
||||
return metadataMap?.get(messageType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置速率限制元数据
|
||||
* @en Set rate limit metadata
|
||||
*
|
||||
* @param target - @zh 目标对象 @en Target object
|
||||
* @param messageType - @zh 消息类型 @en Message type
|
||||
* @param metadata - @zh 元数据 @en Metadata
|
||||
*/
|
||||
function setRateLimitMetadata(target: any, messageType: string, metadata: RateLimitMetadata): void {
|
||||
if (!target[RATE_LIMIT_METADATA_KEY]) {
|
||||
target[RATE_LIMIT_METADATA_KEY] = new Map<string, RateLimitMetadata>();
|
||||
}
|
||||
const metadataMap = target[RATE_LIMIT_METADATA_KEY] as Map<string, RateLimitMetadata>;
|
||||
const existing = metadataMap.get(messageType) ?? { enabled: true };
|
||||
metadataMap.set(messageType, { ...existing, ...metadata });
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 从方法获取消息类型
|
||||
* @en Get message type from method
|
||||
*
|
||||
* @zh 通过查找 onMessage 装饰器设置的元数据来获取消息类型
|
||||
* @en Gets message type by looking up metadata set by onMessage decorator
|
||||
*/
|
||||
function getMessageTypeFromMethod(target: any, methodName: string): string | undefined {
|
||||
const messageHandlers = Symbol.for('messageHandlers');
|
||||
|
||||
for (const sym of Object.getOwnPropertySymbols(target.constructor)) {
|
||||
const desc = Object.getOwnPropertyDescriptor(target.constructor, sym);
|
||||
if (desc?.value && Array.isArray(desc.value)) {
|
||||
for (const handler of desc.value) {
|
||||
if (handler.method === methodName) {
|
||||
return handler.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = target.constructor[Symbol.for('messageHandlers')] as { type: string; method: string }[] | undefined;
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
if (handler.method === methodName) {
|
||||
return handler.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制装饰器
|
||||
* @en Rate limit decorator
|
||||
*
|
||||
* @zh 为消息处理器设置独立的速率限制配置
|
||||
* @en Set independent rate limit configuration for message handler
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // This message has stricter rate limit
|
||||
* }
|
||||
*
|
||||
* @rateLimit({ cost: 5 })
|
||||
* @onMessage('ExpensiveAction')
|
||||
* handleExpensiveAction(data: any, player: Player) {
|
||||
* // This message consumes 5 tokens
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function rateLimit(config?: MessageRateLimitConfig): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const msgType = getMessageTypeFromMethod(target, methodName);
|
||||
if (msgType) {
|
||||
setRateLimitMetadata(target, msgType, {
|
||||
enabled: true,
|
||||
config
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metadata: RateLimitMetadata = {
|
||||
enabled: true,
|
||||
config
|
||||
};
|
||||
|
||||
if (!target.hasOwnProperty(RATE_LIMIT_METADATA_KEY)) {
|
||||
Object.defineProperty(target, RATE_LIMIT_METADATA_KEY, {
|
||||
value: new Map<string, RateLimitMetadata>(),
|
||||
writable: false,
|
||||
enumerable: false
|
||||
});
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 豁免速率限制装饰器
|
||||
* @en Exempt from rate limit decorator
|
||||
*
|
||||
* @zh 标记消息处理器不受速率限制
|
||||
* @en Mark message handler as exempt from rate limit
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @noRateLimit()
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // This message is not rate limited
|
||||
* }
|
||||
*
|
||||
* @noRateLimit()
|
||||
* @onMessage('Ping')
|
||||
* handlePing(data: any, player: Player) {
|
||||
* player.send('Pong', {});
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function noRateLimit(): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
const methodName = String(propertyKey);
|
||||
|
||||
queueMicrotask(() => {
|
||||
const msgType = getMessageTypeFromMethod(target, methodName);
|
||||
if (msgType) {
|
||||
setRateLimitMetadata(target, msgType, {
|
||||
enabled: false,
|
||||
exempt: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制消息装饰器(直接指定消息类型)
|
||||
* @en Rate limit message decorator (directly specify message type)
|
||||
*
|
||||
* @zh 当无法自动获取消息类型时使用此装饰器
|
||||
* @en Use this decorator when message type cannot be obtained automatically
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // Explicitly rate limited
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function rateLimitMessage(
|
||||
messageType: string,
|
||||
config?: MessageRateLimitConfig
|
||||
): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
setRateLimitMetadata(target, messageType, {
|
||||
enabled: true,
|
||||
config
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 豁免速率限制消息装饰器(直接指定消息类型)
|
||||
* @en Exempt rate limit message decorator (directly specify message type)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(Room) {
|
||||
* @noRateLimitMessage('Heartbeat')
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // Explicitly exempted
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function noRateLimitMessage(messageType: string): MethodDecorator {
|
||||
return function (
|
||||
target: Object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
): PropertyDescriptor {
|
||||
setRateLimitMetadata(target, messageType, {
|
||||
enabled: false,
|
||||
exempt: true
|
||||
});
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
91
packages/framework/server/src/ratelimit/index.ts
Normal file
91
packages/framework/server/src/ratelimit/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @zh 速率限制模块
|
||||
* @en Rate limit module
|
||||
*
|
||||
* @zh 提供可插拔的速率限制系统,支持多种限流算法
|
||||
* @en Provides pluggable rate limit system with multiple algorithms
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import {
|
||||
* withRateLimit,
|
||||
* rateLimit,
|
||||
* noRateLimit
|
||||
* } from '@esengine/server/ratelimit';
|
||||
*
|
||||
* class GameRoom extends withRateLimit(Room, {
|
||||
* messagesPerSecond: 10,
|
||||
* burstSize: 20,
|
||||
* strategy: 'token-bucket',
|
||||
* onLimited: (player, type, result) => {
|
||||
* player.send('Error', {
|
||||
* code: 'RATE_LIMITED',
|
||||
* retryAfter: result.retryAfter
|
||||
* });
|
||||
* }
|
||||
* }) {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // Protected by default rate limit
|
||||
* }
|
||||
*
|
||||
* @rateLimit({ messagesPerSecond: 1 })
|
||||
* @onMessage('Trade')
|
||||
* handleTrade(data: TradeData, player: Player) {
|
||||
* // Stricter rate limit for trading
|
||||
* }
|
||||
*
|
||||
* @noRateLimit()
|
||||
* @onMessage('Heartbeat')
|
||||
* handleHeartbeat(data: any, player: Player) {
|
||||
* // No rate limit for heartbeat
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RateLimitResult,
|
||||
IRateLimitStrategy,
|
||||
RateLimitStrategyType,
|
||||
RateLimitConfig,
|
||||
MessageRateLimitConfig,
|
||||
RateLimitMetadata,
|
||||
IRateLimitContext,
|
||||
RateLimitedRoom,
|
||||
StrategyConfig
|
||||
} from './types.js';
|
||||
|
||||
// Strategies
|
||||
export {
|
||||
TokenBucketStrategy,
|
||||
createTokenBucketStrategy,
|
||||
SlidingWindowStrategy,
|
||||
createSlidingWindowStrategy,
|
||||
FixedWindowStrategy,
|
||||
createFixedWindowStrategy
|
||||
} from './strategies/index.js';
|
||||
|
||||
// Context
|
||||
export { RateLimitContext } from './context.js';
|
||||
|
||||
// Mixin
|
||||
export {
|
||||
withRateLimit,
|
||||
getPlayerRateLimitContext,
|
||||
type RateLimitedPlayer,
|
||||
type IRateLimitRoom,
|
||||
type RateLimitRoomClass
|
||||
} from './mixin/index.js';
|
||||
|
||||
// Decorators
|
||||
export {
|
||||
rateLimit,
|
||||
noRateLimit,
|
||||
rateLimitMessage,
|
||||
noRateLimitMessage,
|
||||
getRateLimitMetadata,
|
||||
RATE_LIMIT_METADATA_KEY
|
||||
} from './decorators/index.js';
|
||||
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
12
packages/framework/server/src/ratelimit/mixin/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @zh 速率限制 Mixin
|
||||
* @en Rate limit mixin
|
||||
*/
|
||||
|
||||
export {
|
||||
withRateLimit,
|
||||
getPlayerRateLimitContext,
|
||||
type RateLimitedPlayer,
|
||||
type IRateLimitRoom,
|
||||
type RateLimitRoomClass
|
||||
} from './withRateLimit.js';
|
||||
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal file
385
packages/framework/server/src/ratelimit/mixin/withRateLimit.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @zh 房间速率限制 Mixin
|
||||
* @en Room rate limit mixin
|
||||
*/
|
||||
|
||||
import type { Player, Room } from '../../room/index.js';
|
||||
import { RateLimitContext } from '../context.js';
|
||||
import { getRateLimitMetadata, RATE_LIMIT_METADATA_KEY } from '../decorators/rateLimit.js';
|
||||
import { FixedWindowStrategy } from '../strategies/FixedWindow.js';
|
||||
import { SlidingWindowStrategy } from '../strategies/SlidingWindow.js';
|
||||
import { TokenBucketStrategy } from '../strategies/TokenBucket.js';
|
||||
import type {
|
||||
IRateLimitContext,
|
||||
IRateLimitStrategy,
|
||||
RateLimitConfig,
|
||||
RateLimitMetadata,
|
||||
RateLimitResult
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 玩家速率限制上下文存储
|
||||
* @en Player rate limit context storage
|
||||
*/
|
||||
const PLAYER_RATE_LIMIT_CONTEXT = Symbol('playerRateLimitContext');
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的玩家
|
||||
* @en Player with rate limit
|
||||
*/
|
||||
export interface RateLimitedPlayer<TData = Record<string, unknown>> extends Player<TData> {
|
||||
/**
|
||||
* @zh 速率限制上下文
|
||||
* @en Rate limit context
|
||||
*/
|
||||
readonly rateLimit: IRateLimitContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的房间接口
|
||||
* @en Room with rate limit interface
|
||||
*/
|
||||
export interface IRateLimitRoom {
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null;
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
readonly rateLimitStrategy: IRateLimitStrategy;
|
||||
|
||||
/**
|
||||
* @zh 速率限制钩子(被限流时调用)
|
||||
* @en Rate limit hook (called when rate limited)
|
||||
*/
|
||||
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制房间构造器类型
|
||||
* @en Rate limit room constructor type
|
||||
*/
|
||||
export type RateLimitRoomClass = new (...args: any[]) => Room & IRateLimitRoom;
|
||||
|
||||
/**
|
||||
* @zh 创建策略实例
|
||||
* @en Create strategy instance
|
||||
*/
|
||||
function createStrategy(config: RateLimitConfig): IRateLimitStrategy {
|
||||
const rate = config.messagesPerSecond ?? 10;
|
||||
const capacity = config.burstSize ?? rate * 2;
|
||||
|
||||
switch (config.strategy) {
|
||||
case 'sliding-window':
|
||||
return new SlidingWindowStrategy({ rate, capacity });
|
||||
case 'fixed-window':
|
||||
return new FixedWindowStrategy({ rate, capacity });
|
||||
case 'token-bucket':
|
||||
default:
|
||||
return new TokenBucketStrategy({ rate, capacity });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
export function getPlayerRateLimitContext(player: Player): IRateLimitContext | null {
|
||||
const data = player as unknown as Record<symbol, unknown>;
|
||||
return (data[PLAYER_RATE_LIMIT_CONTEXT] as IRateLimitContext) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 设置玩家的速率限制上下文
|
||||
* @en Set rate limit context for player
|
||||
*/
|
||||
function setPlayerRateLimitContext(player: Player, context: IRateLimitContext): void {
|
||||
const data = player as unknown as Record<symbol, unknown>;
|
||||
data[PLAYER_RATE_LIMIT_CONTEXT] = context;
|
||||
|
||||
Object.defineProperty(player, 'rateLimit', {
|
||||
get: () => data[PLAYER_RATE_LIMIT_CONTEXT],
|
||||
enumerable: true,
|
||||
configurable: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 包装房间类添加速率限制功能
|
||||
* @en Wrap room class with rate limit functionality
|
||||
*
|
||||
* @zh 使用 mixin 模式为房间添加速率限制,在消息处理前验证速率限制
|
||||
* @en Uses mixin pattern to add rate limit to room, validates rate before processing messages
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Room, onMessage } from '@esengine/server';
|
||||
* import { withRateLimit } from '@esengine/server/ratelimit';
|
||||
*
|
||||
* class GameRoom extends withRateLimit(Room, {
|
||||
* messagesPerSecond: 10,
|
||||
* burstSize: 20,
|
||||
* onLimited: (player, type, result) => {
|
||||
* player.send('Error', {
|
||||
* code: 'RATE_LIMITED',
|
||||
* retryAfter: result.retryAfter
|
||||
* });
|
||||
* }
|
||||
* }) {
|
||||
* @onMessage('Move')
|
||||
* handleMove(data: { x: number, y: number }, player: Player) {
|
||||
* // Protected by rate limit
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* // Combine with auth
|
||||
* ```typescript
|
||||
* class GameRoom extends withRateLimit(
|
||||
* withRoomAuth(Room, { requireAuth: true }),
|
||||
* { messagesPerSecond: 10 }
|
||||
* ) {
|
||||
* // Both auth and rate limit active
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function withRateLimit<TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||
Base: TBase,
|
||||
config: RateLimitConfig = {}
|
||||
): TBase & (new (...args: any[]) => IRateLimitRoom) {
|
||||
const {
|
||||
messagesPerSecond = 10,
|
||||
burstSize = 20,
|
||||
strategy = 'token-bucket',
|
||||
onLimited,
|
||||
disconnectOnLimit = false,
|
||||
maxConsecutiveLimits = 0,
|
||||
getKey = (player: Player) => player.id,
|
||||
cleanupInterval = 60000
|
||||
} = config;
|
||||
|
||||
abstract class RateLimitRoom extends (Base as new (...args: any[]) => Room) implements IRateLimitRoom {
|
||||
private _rateLimitStrategy: IRateLimitStrategy;
|
||||
private _playerContexts: WeakMap<Player, RateLimitContext> = new WeakMap();
|
||||
private _cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
this._rateLimitStrategy = createStrategy({
|
||||
messagesPerSecond,
|
||||
burstSize,
|
||||
strategy
|
||||
});
|
||||
|
||||
this._startCleanup();
|
||||
this._initMessageStrategies();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
get rateLimitStrategy(): IRateLimitStrategy {
|
||||
return this._rateLimitStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制钩子(可覆盖)
|
||||
* @en Rate limit hook (can be overridden)
|
||||
*/
|
||||
onRateLimited?(player: Player, messageType: string, result: RateLimitResult): void;
|
||||
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null {
|
||||
return this._playerContexts.get(player) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写消息处理以添加速率限制检查
|
||||
* @en Override message handling to add rate limit check
|
||||
*/
|
||||
_handleMessage(type: string, data: unknown, playerId: string): void {
|
||||
const player = this.getPlayer(playerId);
|
||||
if (!player) return;
|
||||
|
||||
let context = this._playerContexts.get(player);
|
||||
if (!context) {
|
||||
context = this._createPlayerContext(player);
|
||||
}
|
||||
|
||||
const metadata = this._getMessageMetadata(type);
|
||||
|
||||
if (metadata?.exempt) {
|
||||
super._handleMessage(type, data, playerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = metadata?.config?.cost ?? 1;
|
||||
let result: RateLimitResult;
|
||||
|
||||
if (metadata?.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
|
||||
if (!context.hasMessageStrategy(type)) {
|
||||
const msgStrategy = createStrategy({
|
||||
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
|
||||
burstSize: metadata.config.burstSize ?? burstSize,
|
||||
strategy
|
||||
});
|
||||
context.setMessageStrategy(type, msgStrategy);
|
||||
}
|
||||
result = context.consume(type, cost);
|
||||
} else {
|
||||
result = context.consume(undefined, cost);
|
||||
}
|
||||
|
||||
if (!result.allowed) {
|
||||
this._handleRateLimited(player, type, result, context);
|
||||
return;
|
||||
}
|
||||
|
||||
super._handleMessage(type, data, playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写 _addPlayer 以初始化速率限制上下文
|
||||
* @en Override _addPlayer to initialize rate limit context
|
||||
*/
|
||||
async _addPlayer(id: string, conn: any): Promise<Player | null> {
|
||||
const player = await super._addPlayer(id, conn);
|
||||
if (player) {
|
||||
this._createPlayerContext(player);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @zh 重写 _removePlayer 以清理速率限制上下文
|
||||
* @en Override _removePlayer to cleanup rate limit context
|
||||
*/
|
||||
async _removePlayer(id: string, reason?: string): Promise<void> {
|
||||
const player = this.getPlayer(id);
|
||||
if (player) {
|
||||
const context = this._playerContexts.get(player);
|
||||
if (context) {
|
||||
context.reset();
|
||||
}
|
||||
this._playerContexts.delete(player);
|
||||
}
|
||||
await super._removePlayer(id, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重写 dispose 以清理定时器
|
||||
* @en Override dispose to cleanup timer
|
||||
*/
|
||||
dispose(): void {
|
||||
this._stopCleanup();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建玩家的速率限制上下文
|
||||
* @en Create rate limit context for player
|
||||
*/
|
||||
private _createPlayerContext(player: Player): RateLimitContext {
|
||||
const key = getKey(player);
|
||||
const context = new RateLimitContext(key, this._rateLimitStrategy);
|
||||
this._playerContexts.set(player, context);
|
||||
setPlayerRateLimitContext(player, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 处理被限流的情况
|
||||
* @en Handle rate limited situation
|
||||
*/
|
||||
private _handleRateLimited(
|
||||
player: Player,
|
||||
messageType: string,
|
||||
result: RateLimitResult,
|
||||
context: RateLimitContext
|
||||
): void {
|
||||
if (this.onRateLimited) {
|
||||
this.onRateLimited(player, messageType, result);
|
||||
}
|
||||
|
||||
onLimited?.(player, messageType, result);
|
||||
|
||||
if (disconnectOnLimit) {
|
||||
this.kick(player as any, 'rate_limited');
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxConsecutiveLimits > 0 && context.consecutiveLimitCount >= maxConsecutiveLimits) {
|
||||
this.kick(player as any, 'too_many_rate_limits');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取消息的元数据
|
||||
* @en Get message metadata
|
||||
*/
|
||||
private _getMessageMetadata(type: string): RateLimitMetadata | undefined {
|
||||
return getRateLimitMetadata(this.constructor.prototype, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 初始化消息策略(从装饰器元数据)
|
||||
* @en Initialize message strategies (from decorator metadata)
|
||||
*/
|
||||
private _initMessageStrategies(): void {
|
||||
const metadataMap = (this.constructor.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||
if (metadataMap instanceof Map) {
|
||||
for (const [msgType, metadata] of metadataMap) {
|
||||
if (metadata.config && (metadata.config.messagesPerSecond || metadata.config.burstSize)) {
|
||||
const msgStrategy = createStrategy({
|
||||
messagesPerSecond: metadata.config.messagesPerSecond ?? messagesPerSecond,
|
||||
burstSize: metadata.config.burstSize ?? burstSize,
|
||||
strategy
|
||||
});
|
||||
this._messageStrategies.set(msgType, msgStrategy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 开始清理定时器
|
||||
* @en Start cleanup timer
|
||||
*/
|
||||
private _startCleanup(): void {
|
||||
if (cleanupInterval > 0) {
|
||||
this._cleanupTimer = setInterval(() => {
|
||||
this._rateLimitStrategy.cleanup();
|
||||
for (const strategy of this._messageStrategies.values()) {
|
||||
strategy.cleanup();
|
||||
}
|
||||
}, cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 停止清理定时器
|
||||
* @en Stop cleanup timer
|
||||
*/
|
||||
private _stopCleanup(): void {
|
||||
if (this._cleanupTimer) {
|
||||
clearInterval(this._cleanupTimer);
|
||||
this._cleanupTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RateLimitRoom as unknown as TBase & (new (...args: any[]) => IRateLimitRoom);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @zh 固定窗口速率限制策略
|
||||
* @en Fixed window rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 固定窗口状态
|
||||
* @en Fixed window state
|
||||
*/
|
||||
interface WindowState {
|
||||
/**
|
||||
* @zh 当前窗口计数
|
||||
* @en Current window count
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* @zh 窗口开始时间
|
||||
* @en Window start time
|
||||
*/
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 固定窗口速率限制策略
|
||||
* @en Fixed window rate limit strategy
|
||||
*
|
||||
* @zh 固定窗口算法将时间划分为固定长度的窗口,在每个窗口内计数请求。
|
||||
* 实现简单,内存开销小,但在窗口边界可能有两倍突发的问题。
|
||||
* @en Fixed window algorithm divides time into fixed-length windows and counts requests in each window.
|
||||
* Simple to implement with low memory overhead, but may have 2x burst issue at window boundaries.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new FixedWindowStrategy({
|
||||
* rate: 10, // 10 requests per second
|
||||
* capacity: 10 // same as rate for 1-second window
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FixedWindowStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'fixed-window';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _windowMs: number;
|
||||
private _windows: Map<string, WindowState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建固定窗口策略
|
||||
* @en Create fixed window strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
|
||||
* @param config.capacity - @zh 窗口容量 @en Window capacity
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
this._windowMs = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._getOrCreateWindow(key, now);
|
||||
|
||||
this._maybeResetWindow(window, now);
|
||||
|
||||
if (window.count + cost <= this._capacity) {
|
||||
window.count += cost;
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity - window.count,
|
||||
resetAt: window.windowStart + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
const retryAfter = window.windowStart + this._windowMs - now;
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: window.windowStart + this._windowMs,
|
||||
retryAfter: Math.max(0, retryAfter)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: this._getWindowStart(now) + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
this._maybeResetWindow(window, now);
|
||||
|
||||
const remaining = Math.max(0, this._capacity - window.count);
|
||||
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
resetAt: window.windowStart + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期记录
|
||||
* @en Clean up all expired records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const currentWindowStart = this._getWindowStart(now);
|
||||
|
||||
for (const [key, window] of this._windows) {
|
||||
if (window.windowStart < currentWindowStart - this._windowMs) {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建窗口
|
||||
* @en Get or create window
|
||||
*/
|
||||
private _getOrCreateWindow(key: string, now: number): WindowState {
|
||||
let window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
window = {
|
||||
count: 0,
|
||||
windowStart: this._getWindowStart(now)
|
||||
};
|
||||
this._windows.set(key, window);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 如果需要则重置窗口
|
||||
* @en Reset window if needed
|
||||
*/
|
||||
private _maybeResetWindow(window: WindowState, now: number): void {
|
||||
const currentWindowStart = this._getWindowStart(now);
|
||||
|
||||
if (window.windowStart < currentWindowStart) {
|
||||
window.count = 0;
|
||||
window.windowStart = currentWindowStart;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取窗口开始时间
|
||||
* @en Get window start time
|
||||
*/
|
||||
private _getWindowStart(now: number): number {
|
||||
return Math.floor(now / this._windowMs) * this._windowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建固定窗口策略
|
||||
* @en Create fixed window strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createFixedWindowStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 10
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createFixedWindowStrategy(config: StrategyConfig): FixedWindowStrategy {
|
||||
return new FixedWindowStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @zh 滑动窗口速率限制策略
|
||||
* @en Sliding window rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 滑动窗口状态
|
||||
* @en Sliding window state
|
||||
*/
|
||||
interface WindowState {
|
||||
/**
|
||||
* @zh 请求时间戳列表
|
||||
* @en Request timestamp list
|
||||
*/
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 滑动窗口速率限制策略
|
||||
* @en Sliding window rate limit strategy
|
||||
*
|
||||
* @zh 滑动窗口算法精确跟踪时间窗口内的请求数。
|
||||
* 比固定窗口更精确,但内存开销稍大。
|
||||
* @en Sliding window algorithm precisely tracks requests within a time window.
|
||||
* More accurate than fixed window, but with slightly higher memory overhead.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new SlidingWindowStrategy({
|
||||
* rate: 10, // 10 requests per second
|
||||
* capacity: 10 // window size (same as rate for 1-second window)
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SlidingWindowStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'sliding-window';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _windowMs: number;
|
||||
private _windows: Map<string, WindowState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建滑动窗口策略
|
||||
* @en Create sliding window strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒允许的请求数 @en Requests allowed per second
|
||||
* @param config.capacity - @zh 窗口容量 @en Window capacity
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
this._windowMs = 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._getOrCreateWindow(key);
|
||||
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
const currentCount = window.timestamps.length;
|
||||
|
||||
if (currentCount + cost <= this._capacity) {
|
||||
for (let i = 0; i < cost; i++) {
|
||||
window.timestamps.push(now);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity - window.timestamps.length,
|
||||
resetAt: this._getResetAt(window, now)
|
||||
};
|
||||
}
|
||||
|
||||
const oldestTimestamp = window.timestamps[0] || now;
|
||||
const retryAfter = Math.max(0, oldestTimestamp + this._windowMs - now);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: oldestTimestamp + this._windowMs,
|
||||
retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: now + this._windowMs
|
||||
};
|
||||
}
|
||||
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
const remaining = Math.max(0, this._capacity - window.timestamps.length);
|
||||
|
||||
return {
|
||||
allowed: remaining > 0,
|
||||
remaining,
|
||||
resetAt: this._getResetAt(window, now)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期记录
|
||||
* @en Clean up all expired records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, window] of this._windows) {
|
||||
this._cleanExpiredTimestamps(window, now);
|
||||
|
||||
if (window.timestamps.length === 0) {
|
||||
this._windows.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建窗口
|
||||
* @en Get or create window
|
||||
*/
|
||||
private _getOrCreateWindow(key: string): WindowState {
|
||||
let window = this._windows.get(key);
|
||||
|
||||
if (!window) {
|
||||
window = { timestamps: [] };
|
||||
this._windows.set(key, window);
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理过期的时间戳
|
||||
* @en Clean expired timestamps
|
||||
*/
|
||||
private _cleanExpiredTimestamps(window: WindowState, now: number): void {
|
||||
const cutoff = now - this._windowMs;
|
||||
window.timestamps = window.timestamps.filter(ts => ts > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取重置时间
|
||||
* @en Get reset time
|
||||
*/
|
||||
private _getResetAt(window: WindowState, now: number): number {
|
||||
if (window.timestamps.length === 0) {
|
||||
return now + this._windowMs;
|
||||
}
|
||||
|
||||
return window.timestamps[0] + this._windowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建滑动窗口策略
|
||||
* @en Create sliding window strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createSlidingWindowStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 10
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createSlidingWindowStrategy(config: StrategyConfig): SlidingWindowStrategy {
|
||||
return new SlidingWindowStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @zh 令牌桶速率限制策略
|
||||
* @en Token bucket rate limit strategy
|
||||
*/
|
||||
|
||||
import type { IRateLimitStrategy, RateLimitResult, StrategyConfig } from '../types.js';
|
||||
|
||||
/**
|
||||
* @zh 令牌桶状态
|
||||
* @en Token bucket state
|
||||
*/
|
||||
interface BucketState {
|
||||
/**
|
||||
* @zh 当前令牌数
|
||||
* @en Current token count
|
||||
*/
|
||||
tokens: number;
|
||||
|
||||
/**
|
||||
* @zh 上次更新时间
|
||||
* @en Last update time
|
||||
*/
|
||||
lastUpdate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 令牌桶速率限制策略
|
||||
* @en Token bucket rate limit strategy
|
||||
*
|
||||
* @zh 令牌桶算法允许突发流量,同时保持长期速率限制。
|
||||
* 令牌以固定速率添加到桶中,每个请求消耗一个或多个令牌。
|
||||
* @en Token bucket algorithm allows burst traffic while maintaining long-term rate limit.
|
||||
* Tokens are added to the bucket at a fixed rate, each request consumes one or more tokens.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = new TokenBucketStrategy({
|
||||
* rate: 10, // 10 tokens per second
|
||||
* capacity: 20 // bucket can hold 20 tokens max
|
||||
* });
|
||||
*
|
||||
* const result = strategy.consume('player-123');
|
||||
* if (result.allowed) {
|
||||
* // Process message
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class TokenBucketStrategy implements IRateLimitStrategy {
|
||||
readonly name = 'token-bucket';
|
||||
|
||||
private _rate: number;
|
||||
private _capacity: number;
|
||||
private _buckets: Map<string, BucketState> = new Map();
|
||||
|
||||
/**
|
||||
* @zh 创建令牌桶策略
|
||||
* @en Create token bucket strategy
|
||||
*
|
||||
* @param config - @zh 配置 @en Configuration
|
||||
* @param config.rate - @zh 每秒添加的令牌数 @en Tokens added per second
|
||||
* @param config.capacity - @zh 桶容量(最大令牌数)@en Bucket capacity (max tokens)
|
||||
*/
|
||||
constructor(config: StrategyConfig) {
|
||||
this._rate = config.rate;
|
||||
this._capacity = config.capacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 尝试消费令牌
|
||||
* @en Try to consume tokens
|
||||
*/
|
||||
consume(key: string, cost: number = 1): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const bucket = this._getOrCreateBucket(key, now);
|
||||
|
||||
this._refillBucket(bucket, now);
|
||||
|
||||
if (bucket.tokens >= cost) {
|
||||
bucket.tokens -= cost;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.floor(bucket.tokens),
|
||||
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
const tokensNeeded = cost - bucket.tokens;
|
||||
const retryAfter = Math.ceil(tokensNeeded / this._rate * 1000);
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: now + retryAfter,
|
||||
retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态
|
||||
* @en Get current status
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const bucket = this._buckets.get(key);
|
||||
|
||||
if (!bucket) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: this._capacity,
|
||||
resetAt: now
|
||||
};
|
||||
}
|
||||
|
||||
this._refillBucket(bucket, now);
|
||||
|
||||
return {
|
||||
allowed: bucket.tokens >= 1,
|
||||
remaining: Math.floor(bucket.tokens),
|
||||
resetAt: now + Math.ceil((this._capacity - bucket.tokens) / this._rate * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 重置指定键
|
||||
* @en Reset specified key
|
||||
*/
|
||||
reset(key: string): void {
|
||||
this._buckets.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 清理所有记录
|
||||
* @en Clean up all records
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now();
|
||||
const expireThreshold = 60000;
|
||||
|
||||
for (const [key, bucket] of this._buckets) {
|
||||
if (now - bucket.lastUpdate > expireThreshold && bucket.tokens >= this._capacity) {
|
||||
this._buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 获取或创建桶
|
||||
* @en Get or create bucket
|
||||
*/
|
||||
private _getOrCreateBucket(key: string, now: number): BucketState {
|
||||
let bucket = this._buckets.get(key);
|
||||
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
tokens: this._capacity,
|
||||
lastUpdate: now
|
||||
};
|
||||
this._buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 补充令牌
|
||||
* @en Refill tokens
|
||||
*/
|
||||
private _refillBucket(bucket: BucketState, now: number): void {
|
||||
const elapsed = now - bucket.lastUpdate;
|
||||
const tokensToAdd = (elapsed / 1000) * this._rate;
|
||||
|
||||
bucket.tokens = Math.min(this._capacity, bucket.tokens + tokensToAdd);
|
||||
bucket.lastUpdate = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 创建令牌桶策略
|
||||
* @en Create token bucket strategy
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const strategy = createTokenBucketStrategy({
|
||||
* rate: 10,
|
||||
* capacity: 20
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createTokenBucketStrategy(config: StrategyConfig): TokenBucketStrategy {
|
||||
return new TokenBucketStrategy(config);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @zh 速率限制策略
|
||||
* @en Rate limit strategies
|
||||
*/
|
||||
|
||||
export { TokenBucketStrategy, createTokenBucketStrategy } from './TokenBucket.js';
|
||||
export { SlidingWindowStrategy, createSlidingWindowStrategy } from './SlidingWindow.js';
|
||||
export { FixedWindowStrategy, createFixedWindowStrategy } from './FixedWindow.js';
|
||||
267
packages/framework/server/src/ratelimit/types.ts
Normal file
267
packages/framework/server/src/ratelimit/types.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @zh 速率限制类型定义
|
||||
* @en Rate limit type definitions
|
||||
*/
|
||||
|
||||
import type { Player } from '../room/Player.js';
|
||||
|
||||
/**
|
||||
* @zh 速率限制结果
|
||||
* @en Rate limit result
|
||||
*/
|
||||
export interface RateLimitResult {
|
||||
/**
|
||||
* @zh 是否允许
|
||||
* @en Whether allowed
|
||||
*/
|
||||
allowed: boolean;
|
||||
|
||||
/**
|
||||
* @zh 剩余配额
|
||||
* @en Remaining quota
|
||||
*/
|
||||
remaining: number;
|
||||
|
||||
/**
|
||||
* @zh 配额重置时间(毫秒时间戳)
|
||||
* @en Quota reset time (milliseconds timestamp)
|
||||
*/
|
||||
resetAt: number;
|
||||
|
||||
/**
|
||||
* @zh 重试等待时间(毫秒),仅在被限流时返回
|
||||
* @en Retry after time (milliseconds), only returned when rate limited
|
||||
*/
|
||||
retryAfter?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略接口
|
||||
* @en Rate limit strategy interface
|
||||
*/
|
||||
export interface IRateLimitStrategy {
|
||||
/**
|
||||
* @zh 策略名称
|
||||
* @en Strategy name
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @zh 尝试消费配额
|
||||
* @en Try to consume quota
|
||||
*
|
||||
* @param key - @zh 限流键(通常是玩家ID或连接ID)@en Rate limit key (usually player ID or connection ID)
|
||||
* @param cost - @zh 消费数量(默认1)@en Consumption amount (default 1)
|
||||
* @returns @zh 限流结果 @en Rate limit result
|
||||
*/
|
||||
consume(key: string, cost?: number): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 获取当前状态(不消费)
|
||||
* @en Get current status (without consuming)
|
||||
*
|
||||
* @param key - @zh 限流键 @en Rate limit key
|
||||
* @returns @zh 限流结果 @en Rate limit result
|
||||
*/
|
||||
getStatus(key: string): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 重置指定键的限流状态
|
||||
* @en Reset rate limit status for specified key
|
||||
*
|
||||
* @param key - @zh 限流键 @en Rate limit key
|
||||
*/
|
||||
reset(key: string): void;
|
||||
|
||||
/**
|
||||
* @zh 清理所有过期的限流记录
|
||||
* @en Clean up all expired rate limit records
|
||||
*/
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略类型
|
||||
* @en Rate limit strategy type
|
||||
*/
|
||||
export type RateLimitStrategyType = 'token-bucket' | 'sliding-window' | 'fixed-window';
|
||||
|
||||
/**
|
||||
* @zh 速率限制配置
|
||||
* @en Rate limit configuration
|
||||
*/
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* @zh 每秒允许的消息数
|
||||
* @en Messages allowed per second
|
||||
* @defaultValue 10
|
||||
*/
|
||||
messagesPerSecond?: number;
|
||||
|
||||
/**
|
||||
* @zh 突发容量(令牌桶大小)
|
||||
* @en Burst capacity (token bucket size)
|
||||
* @defaultValue 20
|
||||
*/
|
||||
burstSize?: number;
|
||||
|
||||
/**
|
||||
* @zh 限流策略
|
||||
* @en Rate limit strategy
|
||||
* @defaultValue 'token-bucket'
|
||||
*/
|
||||
strategy?: RateLimitStrategyType;
|
||||
|
||||
/**
|
||||
* @zh 被限流时的回调
|
||||
* @en Callback when rate limited
|
||||
*/
|
||||
onLimited?: (player: Player, messageType: string, result: RateLimitResult) => void;
|
||||
|
||||
/**
|
||||
* @zh 是否在限流时断开连接
|
||||
* @en Whether to disconnect when rate limited
|
||||
* @defaultValue false
|
||||
*/
|
||||
disconnectOnLimit?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 连续被限流多少次后断开连接(0 表示不断开)
|
||||
* @en Disconnect after how many consecutive rate limits (0 means never)
|
||||
* @defaultValue 0
|
||||
*/
|
||||
maxConsecutiveLimits?: number;
|
||||
|
||||
/**
|
||||
* @zh 获取限流键的函数(默认使用玩家ID)
|
||||
* @en Function to get rate limit key (default uses player ID)
|
||||
*/
|
||||
getKey?: (player: Player) => string;
|
||||
|
||||
/**
|
||||
* @zh 清理间隔(毫秒)
|
||||
* @en Cleanup interval (milliseconds)
|
||||
* @defaultValue 60000
|
||||
*/
|
||||
cleanupInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 单个消息的速率限制配置
|
||||
* @en Rate limit configuration for individual message
|
||||
*/
|
||||
export interface MessageRateLimitConfig {
|
||||
/**
|
||||
* @zh 每秒允许的消息数
|
||||
* @en Messages allowed per second
|
||||
*/
|
||||
messagesPerSecond?: number;
|
||||
|
||||
/**
|
||||
* @zh 突发容量
|
||||
* @en Burst capacity
|
||||
*/
|
||||
burstSize?: number;
|
||||
|
||||
/**
|
||||
* @zh 消费的令牌数(默认1)
|
||||
* @en Tokens to consume (default 1)
|
||||
*/
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制元数据
|
||||
* @en Rate limit metadata
|
||||
*/
|
||||
export interface RateLimitMetadata {
|
||||
/**
|
||||
* @zh 是否启用速率限制
|
||||
* @en Whether rate limit is enabled
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* @zh 是否豁免速率限制
|
||||
* @en Whether exempt from rate limit
|
||||
*/
|
||||
exempt?: boolean;
|
||||
|
||||
/**
|
||||
* @zh 自定义配置
|
||||
* @en Custom configuration
|
||||
*/
|
||||
config?: MessageRateLimitConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制上下文接口
|
||||
* @en Rate limit context interface
|
||||
*/
|
||||
export interface IRateLimitContext {
|
||||
/**
|
||||
* @zh 检查是否允许(不消费)
|
||||
* @en Check if allowed (without consuming)
|
||||
*/
|
||||
check(messageType?: string): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 消费配额
|
||||
* @en Consume quota
|
||||
*/
|
||||
consume(messageType?: string, cost?: number): RateLimitResult;
|
||||
|
||||
/**
|
||||
* @zh 重置限流状态
|
||||
* @en Reset rate limit status
|
||||
*/
|
||||
reset(messageType?: string): void;
|
||||
|
||||
/**
|
||||
* @zh 获取连续被限流次数
|
||||
* @en Get consecutive limit count
|
||||
*/
|
||||
readonly consecutiveLimitCount: number;
|
||||
|
||||
/**
|
||||
* @zh 重置连续限流计数
|
||||
* @en Reset consecutive limit count
|
||||
*/
|
||||
resetConsecutiveCount(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 带速率限制的 Room 接口
|
||||
* @en Room interface with rate limit
|
||||
*/
|
||||
export interface RateLimitedRoom {
|
||||
/**
|
||||
* @zh 获取玩家的速率限制上下文
|
||||
* @en Get rate limit context for player
|
||||
*/
|
||||
getRateLimitContext(player: Player): IRateLimitContext | null;
|
||||
|
||||
/**
|
||||
* @zh 全局速率限制策略
|
||||
* @en Global rate limit strategy
|
||||
*/
|
||||
readonly rateLimitStrategy: IRateLimitStrategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh 速率限制策略配置
|
||||
* @en Rate limit strategy configuration
|
||||
*/
|
||||
export interface StrategyConfig {
|
||||
/**
|
||||
* @zh 每秒允许的请求数
|
||||
* @en Requests allowed per second
|
||||
*/
|
||||
rate: number;
|
||||
|
||||
/**
|
||||
* @zh 容量/窗口大小
|
||||
* @en Capacity/window size
|
||||
*/
|
||||
capacity: number;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/testing/index.ts'],
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/auth/index.ts',
|
||||
'src/auth/testing/index.ts',
|
||||
'src/ratelimit/index.ts',
|
||||
'src/testing/index.ts'
|
||||
],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
external: ['ws', '@esengine/rpc', '@esengine/rpc/codec'],
|
||||
external: ['ws', 'jsonwebtoken', '@esengine/rpc', '@esengine/rpc/codec'],
|
||||
treeshake: true,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @esengine/transaction
|
||||
|
||||
## 2.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70)]:
|
||||
- @esengine/server@1.3.0
|
||||
|
||||
## 2.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e)]:
|
||||
- @esengine/server@1.2.0
|
||||
|
||||
## 2.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esengine/transaction",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.2",
|
||||
"description": "Game transaction system with distributed support | 游戏事务系统,支持分布式事务",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
95
pnpm-lock.yaml
generated
95
pnpm-lock.yaml
generated
@@ -1688,12 +1688,18 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../rpc
|
||||
devDependencies:
|
||||
'@types/jsonwebtoken':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.10
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.27
|
||||
'@types/ws':
|
||||
specifier: ^8.5.13
|
||||
version: 8.18.1
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.3
|
||||
rimraf:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.10
|
||||
@@ -5183,6 +5189,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -5911,6 +5920,9 @@ packages:
|
||||
resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==}
|
||||
engines: {node: '>=16.20.1'}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -6604,6 +6616,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
editorconfig@0.15.3:
|
||||
resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==}
|
||||
hasBin: true
|
||||
@@ -7968,6 +7983,10 @@ packages:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
@@ -7977,6 +7996,12 @@ packages:
|
||||
just-diff@6.0.2:
|
||||
resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
k8w-crypto@0.2.0:
|
||||
resolution: {integrity: sha512-M6u4eQ6CQaU5xO3s4zaUUp9G79xNDhXtTU0X7N80tDcBhQC5ggowlyOzj95v7WiCuk7xkV0aFsTmCpuf0m0djw==}
|
||||
|
||||
@@ -8176,9 +8201,15 @@ packages:
|
||||
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
|
||||
deprecated: This package is deprecated. Use the optional chaining (?.) operator instead.
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
@@ -8186,9 +8217,15 @@ packages:
|
||||
lodash.isfunction@3.0.9:
|
||||
resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.ismatch@4.4.0:
|
||||
resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
@@ -8207,6 +8244,9 @@ packages:
|
||||
lodash.mergewith@4.6.2:
|
||||
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
@@ -14889,6 +14929,11 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
'@types/node': 20.19.27
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
@@ -15159,6 +15204,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 1.2.0
|
||||
@@ -15871,6 +15924,8 @@ snapshots:
|
||||
|
||||
bson@6.10.4: {}
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -16506,6 +16561,10 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
editorconfig@0.15.3:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
@@ -18369,6 +18428,19 @@ snapshots:
|
||||
|
||||
jsonparse@1.3.1: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
@@ -18380,6 +18452,17 @@ snapshots:
|
||||
|
||||
just-diff@6.0.2: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
k8w-crypto@0.2.0: {}
|
||||
|
||||
k8w-extend-native@1.4.6:
|
||||
@@ -18645,14 +18728,22 @@ snapshots:
|
||||
|
||||
lodash.get@4.4.2: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isequal@4.5.0: {}
|
||||
|
||||
lodash.isfunction@3.0.9: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.ismatch@4.4.0: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
@@ -18665,6 +18756,8 @@ snapshots:
|
||||
|
||||
lodash.mergewith@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash.startcase@4.4.0: {}
|
||||
@@ -22075,7 +22168,7 @@ snapshots:
|
||||
vitest@2.1.9(@types/node@22.19.3)(jsdom@20.0.3)(lightningcss@1.30.2)(terser@5.44.1):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@20.19.27)(lightningcss@1.30.2)(terser@5.44.1))
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.3)(lightningcss@1.30.2)(terser@5.44.1))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
|
||||
Reference in New Issue
Block a user