Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Socket ticketing system, createTestSocket utility #379

Merged
merged 4 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"hygen": "^6.2.11",
"lint-staged": ">=10",
"supertest": "^7.0.0",
"superwstest": "^2.0.4",
"ts-node": "^10.7.0",
"tsx": "^4.11.0",
"typescript": "^5.4.5",
Expand Down
2 changes: 2 additions & 0 deletions src/config/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import playerAuthMiddleware from '../middlewares/player-auth-middleware'
import PlayerAuthAPIService from '../services/api/player-auth-api.service'
import continunityMiddleware from '../middlewares/continunity-middleware'
import PlayerGroupAPIService from '../services/api/player-group-api.service'
import SocketTicketAPIService from '../services/api/socket-ticket-api.service'

export default function configureAPIRoutes(app: Koa) {
app.use(apiKeyMiddleware)
Expand All @@ -33,6 +34,7 @@ export default function configureAPIRoutes(app: Koa) {
app.use(playerAuthMiddleware)
app.use(continunityMiddleware)

app.use(service('/v1/socket-tickets', new SocketTicketAPIService()))
app.use(service('/v1/game-channels', new GameChannelAPIService()))
app.use(service('/v1/player-groups', new PlayerGroupAPIService()))
app.use(service('/v1/health-check', new HealthCheckAPIService()))
Expand Down
18 changes: 18 additions & 0 deletions src/docs/socket-tickets-api.docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SocketTicketAPIService from '../services/api/socket-ticket-api.service'
import APIDocs from './api-docs'

const SocketTicketAPIDocs: APIDocs<SocketTicketAPIService> = {
post: {
description: 'Create a socket ticket (expires after 5 minutes)',
samples: [
{
title: 'Sample response',
sample: {
ticket: '6c6ef345-0ac3-4edc-a221-b807fbbac4ac'
}
}
]
}
}

export default SocketTicketAPIDocs
7 changes: 1 addition & 6 deletions src/services/api/health-check-api.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { Response, Routes } from 'koa-clay'
import { Response } from 'koa-clay'
import APIService from './api-service'

@Routes([
{
method: 'GET'
}
])
export default class HealthCheckAPIService extends APIService {
async index(): Promise<Response> {
return {
Expand Down
31 changes: 31 additions & 0 deletions src/services/api/socket-ticket-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Docs, Request, Response } from 'koa-clay'
import APIService from './api-service'
import { createRedisConnection } from '../../config/redis.config'
import { v4 } from 'uuid'
import Redis from 'ioredis'
import APIKey from '../../entities/api-key'
import SocketTicketAPIDocs from '../../docs/socket-tickets-api.docs'

export async function createSocketTicket(redis: Redis, key: APIKey, devBuild: boolean): Promise<string> {
const ticket = v4()
const payload = `${key.id}:${devBuild ? '1' : '0'}`
await redis.set(`socketTickets.${ticket}`, payload, 'EX', 300)

return ticket
}

export default class SocketTicketAPIService extends APIService {
@Docs(SocketTicketAPIDocs.post)
async post(req: Request): Promise<Response> {
const redis = createRedisConnection(req.ctx)

const ticket = await createSocketTicket(redis, req.ctx.state.key, req.headers['x-talo-dev-build'] === '1')

return {
status: 200,
body: {
ticket
}
}
}
}
25 changes: 0 additions & 25 deletions src/socket/authenticateSocket.ts

This file was deleted.

18 changes: 13 additions & 5 deletions src/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { IncomingMessage, Server } from 'http'
import { RawData, WebSocket, WebSocketServer } from 'ws'
import { captureException } from '@sentry/node'
import { EntityManager, RequestContext } from '@mikro-orm/mysql'
import authenticateSocket from './authenticateSocket'
import SocketConnection from './socketConnection'
import SocketRouter from './router/socketRouter'
import { sendMessage } from './messages/socketMessage'
Expand All @@ -11,6 +10,9 @@ import { Queue } from 'bullmq'
import { createSocketEventQueue, SocketEventData } from './socketEvent'
import { ClickHouseClient } from '@clickhouse/client'
import createClickhouseClient from '../lib/clickhouse/createClient'
import Redis from 'ioredis'
import redisConfig from '../config/redis.config'
import SocketTicket from './socketTicket'

type CloseConnectionOptions = {
code?: number
Expand Down Expand Up @@ -73,10 +75,14 @@ export default class Socket {
async handleConnection(ws: WebSocket, req: IncomingMessage): Promise<void> {
logConnection(req)

const redis = new Redis(redisConfig)

await RequestContext.create(this.em, async () => {
const key = await authenticateSocket(req.headers?.authorization ?? '')
if (key) {
const connection = new SocketConnection(this, ws, key, req)
const url = new URL(req.url, 'http://localhost')
const ticket = new SocketTicket(url.searchParams.get('ticket') ?? '')

if (await ticket.validate(redis)) {
const connection = new SocketConnection(this, ws, ticket, req.socket.remoteAddress)
this.connections.set(ws, connection)

await this.trackEvent('open', {
Expand All @@ -85,14 +91,16 @@ export default class Socket {
code: null,
gameId: connection.game.id,
playerAliasId: null,
devBuild: req.headers['x-talo-dev-build'] === '1'
devBuild: ticket.devBuild
})

await sendMessage(connection, 'v1.connected', {})
} else {
await this.closeConnection(ws)
}
})

await redis.quit()
}

async handleMessage(ws: WebSocket, data: RawData): Promise<void> {
Expand Down
39 changes: 13 additions & 26 deletions src/socket/socketConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { WebSocket } from 'ws'
import PlayerAlias from '../entities/player-alias'
import Game from '../entities/game'
import APIKey, { APIKeyScope } from '../entities/api-key'
import { IncomingHttpHeaders, IncomingMessage } from 'http'
import { RequestContext } from '@mikro-orm/core'
import jwt from 'jsonwebtoken'
import { v4 } from 'uuid'
import Redis from 'ioredis'
import redisConfig from '../config/redis.config'
Expand All @@ -13,28 +11,25 @@ import Socket from '.'
import { SocketMessageResponse } from './messages/socketMessage'
import { logResponse } from './messages/socketLogger'
import { SocketErrorCode } from './messages/socketError'
import SocketTicket from './socketTicket'

export default class SocketConnection {
alive: boolean = true
playerAliasId: number | null = null
game: Game | null = null
private scopes: APIKeyScope[] = []
private headers: IncomingHttpHeaders = {}
private remoteAddress: string = 'unknown'
playerAliasId: number
game: Game
private apiKey: APIKey

rateLimitKey: string = v4()
rateLimitWarnings: number = 0

constructor(
private readonly wss: Socket,
private readonly ws: WebSocket,
apiKey: APIKey,
req: IncomingMessage
private readonly ticket: SocketTicket,
private readonly remoteAddress: string
) {
this.game = apiKey.game
this.scopes = apiKey.scopes
this.headers = req.headers
this.remoteAddress = req.socket.remoteAddress
this.game = this.ticket.apiKey.game
this.apiKey = this.ticket.apiKey
}

async getPlayerAlias(): Promise<PlayerAlias | null> {
Expand All @@ -44,27 +39,19 @@ export default class SocketConnection {
}

getAPIKeyId(): number {
const token = this.headers.authorization.split('Bearer ')[1]
const decodedToken = jwt.decode(token)
return decodedToken.sub
return this.ticket.apiKey.id
}

hasScope(scope: APIKeyScope): boolean {
return this.scopes.includes(APIKeyScope.FULL_ACCESS) || this.scopes.includes(scope)
return this.apiKey.scopes.includes(APIKeyScope.FULL_ACCESS) || this.apiKey.scopes.includes(scope)
}

hasScopes(scopes: APIKeyScope[]): boolean {
if (this.hasScope(APIKeyScope.FULL_ACCESS)) {
return true
}
return scopes.every((scope) => this.hasScope(scope))
return this.hasScope(APIKeyScope.FULL_ACCESS) || scopes.every((scope) => this.hasScope(scope))
}

getRateLimitMaxRequests(): number {
if (this.playerAliasId) {
return 100
}
return 10
return this.playerAliasId ? 100 : 10
}

async checkRateLimitExceeded(): Promise<boolean> {
Expand All @@ -88,7 +75,7 @@ export default class SocketConnection {
}

isDevBuild(): boolean {
return this.headers['x-talo-dev-build'] === '1'
return this.ticket.devBuild
}

async sendMessage<T extends object>(res: SocketMessageResponse, data: T): Promise<void> {
Expand Down
35 changes: 35 additions & 0 deletions src/socket/socketTicket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Redis } from 'ioredis'
import APIKey from '../entities/api-key'
import { RequestContext } from '@mikro-orm/mysql'

export default class SocketTicket {
apiKey: APIKey
devBuild: boolean

constructor(private readonly ticket: string) { }

async validate(redis: Redis): Promise<boolean> {
const ticketValue = await redis.get(`socketTickets.${this.ticket}`)
if (ticketValue) {
await redis.del(`socketTickets.${this.ticket}`)
const [keyId, devBuild] = ticketValue.split(':')

try {
this.devBuild = devBuild === '1'

const em = RequestContext.getEntityManager()
this.apiKey = await em.getRepository(APIKey).findOneOrFail({
id: Number(keyId),
revokedAt: null
}, {
populate: ['game']
})

return true
} catch (error) {
return false
}
}
return false
}
}
Loading
Loading