From 2b797e73a933858fb5e470feb88bb31d666ee2c7 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:31:36 +0000 Subject: [PATCH 1/3] add player self service routes for deletion --- package-lock.json | 7 + package.json | 1 + src/config/public-routes.ts | 6 +- src/entities/game.ts | 16 ++ src/lib/auth/jwt.ts | 16 +- src/lib/logging/buildPlayerAuthActivity.ts | 31 +++ src/lib/routing/state.ts | 2 +- src/routes/api/player-auth/common.ts | 37 ++-- src/routes/api/player-auth/delete.ts | 130 +++++++----- src/routes/api/player-auth/login.ts | 148 ++++++++----- src/routes/api/player-auth/verify.ts | 135 +++++++----- src/routes/protected/game/settings.ts | 1 + src/routes/public/player-public/common.ts | 42 ++++ src/routes/public/player-public/delete.ts | 49 +++++ src/routes/public/player-public/game.ts | 20 ++ src/routes/public/player-public/index.ts | 14 ++ src/routes/public/player-public/login.ts | 38 ++++ src/routes/public/player-public/verify.ts | 40 ++++ tests/routes/protected/game/settings.test.ts | 1 + .../public/player-public/delete.test.ts | 159 ++++++++++++++ .../routes/public/player-public/game.test.ts | 25 +++ .../routes/public/player-public/login.test.ts | 195 ++++++++++++++++++ .../public/player-public/verify.test.ts | 149 +++++++++++++ 23 files changed, 1084 insertions(+), 178 deletions(-) create mode 100644 src/lib/logging/buildPlayerAuthActivity.ts create mode 100644 src/routes/public/player-public/common.ts create mode 100644 src/routes/public/player-public/delete.ts create mode 100644 src/routes/public/player-public/game.ts create mode 100644 src/routes/public/player-public/index.ts create mode 100644 src/routes/public/player-public/login.ts create mode 100644 src/routes/public/player-public/verify.ts create mode 100644 tests/routes/public/player-public/delete.test.ts create mode 100644 tests/routes/public/player-public/game.test.ts create mode 100644 tests/routes/public/player-public/login.test.ts create mode 100644 tests/routes/public/player-public/verify.test.ts diff --git a/package-lock.json b/package-lock.json index a5e815b6..37b5172e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "qrcode": "^1.5.0", "qs": "^6.14.2", "rate-limiter-flexible": "^7.3.0", + "sqids": "^0.3.0", "stripe": "^18.0.0", "uuid": "^9.0.0", "ws": "^8.18.0", @@ -9855,6 +9856,12 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/sqids": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqids/-/sqids-0.3.0.tgz", + "integrity": "sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==", + "license": "MIT" + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", diff --git a/package.json b/package.json index f7e63861..d4638bee 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "qrcode": "^1.5.0", "qs": "^6.14.2", "rate-limiter-flexible": "^7.3.0", + "sqids": "^0.3.0", "stripe": "^18.0.0", "uuid": "^9.0.0", "ws": "^8.18.0", diff --git a/src/config/public-routes.ts b/src/config/public-routes.ts index fedadfa3..9ef74875 100644 --- a/src/config/public-routes.ts +++ b/src/config/public-routes.ts @@ -3,14 +3,16 @@ import { demoRouter } from '../routes/public/demo' import { documentationRouter } from '../routes/public/documentation' import { healthCheckRouter } from '../routes/public/health-check' import { invitePublicRouter } from '../routes/public/invite-public' +import { playerPublicRouter } from '../routes/public/player-public' import { userPublicRouter } from '../routes/public/user-public' import { webhookRouter } from '../routes/public/webhook' export function configurePublicRoutes(app: Koa) { + app.use(demoRouter().routes()) app.use(documentationRouter().routes()) app.use(healthCheckRouter().routes()) - app.use(userPublicRouter().routes()) - app.use(demoRouter().routes()) app.use(invitePublicRouter().routes()) + app.use(playerPublicRouter().routes()) + app.use(userPublicRouter().routes()) app.use(webhookRouter().routes()) } diff --git a/src/entities/game.ts b/src/entities/game.ts index 41fff2aa..d1a16d8d 100644 --- a/src/entities/game.ts +++ b/src/entities/game.ts @@ -2,12 +2,14 @@ import { Collection, Embedded, Entity, + EntityManager, ManyToOne, OneToMany, OneToOne, PrimaryKey, Property, } from '@mikro-orm/mysql' +import Sqids from 'sqids' import GameSecret from './game-secret' import Organisation from './organisation' import Player from './player' @@ -61,6 +63,20 @@ export default class Game { this.organisation = organisation } + getToken() { + return new Sqids({ minLength: 8 }).encode([this.id]) + } + + static async fromToken(token: string, em: EntityManager) { + const ids = new Sqids({ minLength: 8 }).decode(token) + + if (ids.length !== 1) { + return null + } + + return em.repo(Game).findOne({ id: ids[0] }) + } + static getLiveConfigCacheKey(game: Game) { return `live-config-${game.id}` } diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 251d5465..cefd6c00 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -7,16 +7,24 @@ export function sign( ): Promise { return new Promise((resolve, reject) => { jwt.sign(payload, secret, options, (err, token) => { - if (err) reject(err) + if (err) { + return reject(err) + } resolve(token as string) }) }) } -export function verify(token: string, secret: string): Promise { +export function verify( + token: string, + secret: string, + options: jwt.VerifyOptions = {}, +): Promise { return new Promise((resolve, reject) => { - jwt.verify(token, secret, (err, decoded) => { - if (err) reject(err) + jwt.verify(token, secret, options, (err, decoded) => { + if (err) { + return reject(err) + } resolve(decoded as T) }) }) diff --git a/src/lib/logging/buildPlayerAuthActivity.ts b/src/lib/logging/buildPlayerAuthActivity.ts new file mode 100644 index 00000000..71f3724e --- /dev/null +++ b/src/lib/logging/buildPlayerAuthActivity.ts @@ -0,0 +1,31 @@ +import { EntityManager } from '@mikro-orm/mysql' +import Player from '../../entities/player' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../entities/player-auth-activity' + +export function buildPlayerAuthActivity({ + em, + player, + type, + ip, + userAgent, + extra, +}: { + em: EntityManager + player: Player + type: PlayerAuthActivityType + ip: string + userAgent?: string + extra?: Record +}) { + const activity = new PlayerAuthActivity(player) + activity.type = type + activity.extra = { + ...extra, + userAgent, + ip: type === PlayerAuthActivityType.DELETED_AUTH ? undefined : ip, + } + + em.persist(activity) + + return activity +} diff --git a/src/lib/routing/state.ts b/src/lib/routing/state.ts index b5956951..90e1523e 100644 --- a/src/lib/routing/state.ts +++ b/src/lib/routing/state.ts @@ -2,7 +2,7 @@ import type APIKey from '../../entities/api-key' import type Game from '../../entities/game' import type User from '../../entities/user' -export type PublicRouteState = Record +export type PublicRouteState = Record export type ProtectedRouteState = { jwt: { diff --git a/src/routes/api/player-auth/common.ts b/src/routes/api/player-auth/common.ts index 41e70993..b40ba46d 100644 --- a/src/routes/api/player-auth/common.ts +++ b/src/routes/api/player-auth/common.ts @@ -1,9 +1,11 @@ import type { Next } from 'koa' +import assert from 'node:assert' import type { APIRouteContext } from '../../../lib/routing/context' import APIKey from '../../../entities/api-key' import Player from '../../../entities/player' import PlayerAlias from '../../../entities/player-alias' import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../entities/player-auth-activity' +import { buildPlayerAuthActivity } from '../../../lib/logging/buildPlayerAuthActivity' export type PlayerAuthRouteState = { alias: PlayerAlias @@ -27,37 +29,30 @@ export async function loadAliasWithAuth(ctx: APIRouteContext }, ): PlayerAuthActivity { - const ip = ctx.request.ip - - const activity = new PlayerAuthActivity(player) - activity.type = data.type - activity.extra = { - ...data.extra, + return buildPlayerAuthActivity({ + em: ctx.em, + player, + type: data.type, + ip: ctx.request.ip, userAgent: ctx.request.headers['user-agent'], - ip: data.type === PlayerAuthActivityType.DELETED_AUTH ? undefined : ip, - } - - ctx.em.persist(activity) + extra: data.extra, + }) +} - return activity +export function sessionBuilder(alias: PlayerAlias) { + assert(alias.player.auth) + return alias.player.auth.createSession(alias) } diff --git a/src/routes/api/player-auth/delete.ts b/src/routes/api/player-auth/delete.ts index f1726871..66bea773 100644 --- a/src/routes/api/player-auth/delete.ts +++ b/src/routes/api/player-auth/delete.ts @@ -1,18 +1,94 @@ +import type { EntityManager } from '@mikro-orm/mysql' import bcrypt from 'bcrypt' import assert from 'node:assert' import { APIKeyScope } from '../../../entities/api-key' import PlayerAlias from '../../../entities/player-alias' import PlayerAuth from '../../../entities/player-auth' import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../entities/player-auth-activity' +import { buildPlayerAuthActivity } from '../../../lib/logging/buildPlayerAuthActivity' import { apiRoute, withMiddleware } from '../../../lib/routing/router' import { playerAliasHeaderSchema } from '../../../lib/validation/playerAliasHeaderSchema' import { playerHeaderSchema } from '../../../lib/validation/playerHeaderSchema' import { sessionHeaderSchema } from '../../../lib/validation/sessionHeaderSchema' import { requireScopes } from '../../../middleware/policy-middleware' import { deleteClickHousePlayerData } from '../../../tasks/deletePlayers' -import { createPlayerAuthActivity, loadAliasWithAuth } from './common' +import { loadAliasWithAuth } from './common' import { deleteDocs } from './docs' +export async function performDelete({ + em, + alias, + ip, + userAgent, +}: { + em: EntityManager + alias: PlayerAlias + ip: string + userAgent?: string +}) { + await em.repo(PlayerAuthActivity).nativeDelete({ player: alias.player }) + + await em.transactional(async (trx) => { + buildPlayerAuthActivity({ + em: trx, + player: alias.player, + type: PlayerAuthActivityType.DELETED_AUTH, + ip, + userAgent, + extra: { identifier: alias.identifier }, + }) + + assert(alias.player.auth) + trx.remove(trx.repo(PlayerAuth).getReference(alias.player.auth.id)) + trx.remove(trx.repo(PlayerAlias).getReference(alias.id)) + + await deleteClickHousePlayerData({ + playerIds: [alias.player.id], + aliasIds: [alias.id], + }) + }) + + return { status: 204 } +} + +export async function deleteHandler({ + em, + alias, + currentPassword, + ip, + userAgent, +}: { + em: EntityManager + alias: PlayerAlias + currentPassword: string + ip: string + userAgent?: string +}) { + if (!alias.player.auth) { + return { status: 400, body: { message: 'Player does not have authentication' } } + } + + const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password) + if (!passwordMatches) { + buildPlayerAuthActivity({ + em, + player: alias.player, + type: PlayerAuthActivityType.DELETE_AUTH_FAILED, + ip, + userAgent, + extra: { errorCode: 'INVALID_CREDENTIALS' }, + }) + await em.flush() + + return { + status: 403, + body: { message: 'Current password is incorrect', errorCode: 'INVALID_CREDENTIALS' }, + } + } + + return performDelete({ em, alias, ip, userAgent }) +} + export const deleteRoute = apiRoute({ method: 'delete', docs: deleteDocs, @@ -32,53 +108,13 @@ export const deleteRoute = apiRoute({ ), handler: async (ctx) => { const { currentPassword } = ctx.state.validated.body - const em = ctx.em - - const alias = ctx.state.alias - if (!alias.player.auth) { - return ctx.throw(400, 'Player does not have authentication') - } - - const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password) - if (!passwordMatches) { - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.DELETE_AUTH_FAILED, - extra: { - errorCode: 'INVALID_CREDENTIALS', - }, - }) - await em.flush() - - return ctx.throw(403, { - message: 'Current password is incorrect', - errorCode: 'INVALID_CREDENTIALS', - }) - } - - await em.repo(PlayerAuthActivity).nativeDelete({ - player: alias.player, - }) - - await em.transactional(async (trx) => { - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.DELETED_AUTH, - extra: { - identifier: alias.identifier, - }, - }) - - assert(alias.player.auth) - trx.remove(trx.repo(PlayerAuth).getReference(alias.player.auth.id)) - trx.remove(trx.repo(PlayerAlias).getReference(alias.id)) - await deleteClickHousePlayerData({ - playerIds: [alias.player.id], - aliasIds: [alias.id], - }) + return deleteHandler({ + em: ctx.em, + alias: ctx.state.alias, + currentPassword, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], }) - - return { - status: 204, - } }, }) diff --git a/src/routes/api/player-auth/login.ts b/src/routes/api/player-auth/login.ts index 742f3d59..edfcfcd3 100644 --- a/src/routes/api/player-auth/login.ts +++ b/src/routes/api/player-auth/login.ts @@ -1,17 +1,106 @@ +import type { EntityManager } from '@mikro-orm/mysql' +import type Redis from 'ioredis' import bcrypt from 'bcrypt' +import assert from 'node:assert' import { getGlobalQueue } from '../../../config/global-queues' import PlayerAuthCode from '../../../emails/player-auth-code-mail' import { APIKeyScope } from '../../../entities/api-key' +import PlayerAlias from '../../../entities/player-alias' import { PlayerAliasService } from '../../../entities/player-alias' import { PlayerAuthActivityType } from '../../../entities/player-auth-activity' import generateSixDigitCode from '../../../lib/auth/generateSixDigitCode' +import { buildPlayerAuthActivity } from '../../../lib/logging/buildPlayerAuthActivity' import queueEmail from '../../../lib/messaging/queueEmail' import { findAliasFromIdentifyRequest } from '../../../lib/players/findAlias' import { apiRoute, withMiddleware } from '../../../lib/routing/router' import { requireScopes } from '../../../middleware/policy-middleware' -import { createPlayerAuthActivity, getRedisAuthKey, handleFailedLogin } from './common' +import { getRedisAuthKey, sessionBuilder as defaultSessionBuilder } from './common' import { loginDocs } from './docs' +export async function loginHandler({ + em, + alias, + password, + redis, + ip, + userAgent, + sessionBuilder = defaultSessionBuilder, + createSocketToken = true, +}: { + alias: PlayerAlias | null + password: string + em: EntityManager + redis: Redis + ip: string + userAgent?: string + sessionBuilder?: (alias: PlayerAlias) => Promise + createSocketToken?: boolean +}) { + if (!alias) { + return { + status: 401, + body: { message: 'Incorrect identifier or password', errorCode: 'INVALID_CREDENTIALS' }, + } + } + + await em.populate(alias, ['player.auth']) + assert(alias.player.auth) + + const passwordMatches = await bcrypt.compare(password, alias.player.auth.password) + if (!passwordMatches) { + return { + status: 401, + body: { message: 'Incorrect identifier or password', errorCode: 'INVALID_CREDENTIALS' }, + } + } + + if (alias.player.auth.verificationEnabled) { + const code = generateSixDigitCode() + await redis.set(getRedisAuthKey(alias), code, 'EX', 300) + await queueEmail(getGlobalQueue('email'), new PlayerAuthCode(alias, code)) + + buildPlayerAuthActivity({ + em, + player: alias.player, + type: PlayerAuthActivityType.VERIFICATION_STARTED, + ip, + userAgent, + }) + + await em.flush() + + return { + status: 200, + body: { + aliasId: alias.id, + verificationRequired: true, + }, + } + } else { + const sessionToken = await sessionBuilder(alias) + const socketToken = createSocketToken ? await alias.createSocketToken(redis) : undefined + + buildPlayerAuthActivity({ + em, + player: alias.player, + type: PlayerAuthActivityType.LOGGED_IN, + ip, + userAgent, + }) + + await em.flush() + + return { + status: 200, + body: { + alias, + sessionToken, + socketToken, + }, + } + } +} + export const loginRoute = apiRoute({ method: 'post', path: '/login', @@ -29,7 +118,6 @@ export const loginRoute = apiRoute({ handler: async (ctx) => { const { identifier, password } = ctx.state.validated.body const em = ctx.em - const key = ctx.state.key const alias = await findAliasFromIdentifyRequest({ @@ -38,54 +126,14 @@ export const loginRoute = apiRoute({ service: PlayerAliasService.TALO, identifier, }) - if (!alias) return handleFailedLogin(ctx) - - await em.populate(alias, ['player.auth']) - if (!alias.player.auth) return handleFailedLogin(ctx) - - const passwordMatches = await bcrypt.compare(password, alias.player.auth.password) - if (!passwordMatches) return handleFailedLogin(ctx) - - const redis = ctx.redis - - if (alias.player.auth.verificationEnabled) { - await em.populate(alias.player, ['game']) - - const code = generateSixDigitCode() - await redis.set(getRedisAuthKey(key, alias), code, 'EX', 300) - await queueEmail(getGlobalQueue('email'), new PlayerAuthCode(alias, code)) - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.VERIFICATION_STARTED, - }) - - await em.flush() - - return { - status: 200, - body: { - aliasId: alias.id, - verificationRequired: true, - }, - } - } else { - const sessionToken = await alias.player.auth.createSession(alias) - const socketToken = await alias.createSocketToken(redis) - - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.LOGGED_IN, - }) - - await em.flush() - - return { - status: 200, - body: { - alias, - sessionToken, - socketToken, - }, - } - } + return loginHandler({ + em, + alias, + password, + redis: ctx.redis, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], + }) }, }) diff --git a/src/routes/api/player-auth/verify.ts b/src/routes/api/player-auth/verify.ts index 1b6f275b..4a3fd990 100644 --- a/src/routes/api/player-auth/verify.ts +++ b/src/routes/api/player-auth/verify.ts @@ -1,11 +1,83 @@ +import type { EntityManager } from '@mikro-orm/mysql' +import type Redis from 'ioredis' import { APIKeyScope } from '../../../entities/api-key' import PlayerAlias from '../../../entities/player-alias' import { PlayerAuthActivityType } from '../../../entities/player-auth-activity' +import { buildPlayerAuthActivity } from '../../../lib/logging/buildPlayerAuthActivity' import { apiRoute, withMiddleware } from '../../../lib/routing/router' import { requireScopes } from '../../../middleware/policy-middleware' -import { createPlayerAuthActivity, getRedisAuthKey } from './common' +import { getRedisAuthKey, sessionBuilder as defaultSessionBuilder } from './common' import { verifyDocs } from './docs' +export async function verifyHandler({ + em, + alias, + code, + redis, + ip, + userAgent, + sessionBuilder = defaultSessionBuilder, + createSocketToken = true, +}: { + em: EntityManager + alias: PlayerAlias | null + code: string + redis: Redis + ip: string + userAgent?: string + sessionBuilder?: (alias: PlayerAlias) => Promise + createSocketToken?: boolean +}) { + if (!alias) { + return { + status: 403, + body: { message: 'Player alias not found', errorCode: 'VERIFICATION_ALIAS_NOT_FOUND' }, + } + } + + if (!alias.player.auth) { + return { status: 400, body: { message: 'Player does not have authentication' } } + } + + const redisCode = await redis.get(getRedisAuthKey(alias)) + + if (!redisCode || code !== redisCode) { + buildPlayerAuthActivity({ + em, + player: alias.player, + type: PlayerAuthActivityType.VERIFICATION_FAILED, + ip, + userAgent, + }) + await em.flush() + + return { + status: 403, + body: { message: 'Invalid code', errorCode: 'VERIFICATION_CODE_INVALID' }, + } + } + + await redis.del(getRedisAuthKey(alias)) + + const sessionToken = await sessionBuilder(alias) + const socketToken = createSocketToken ? await alias.createSocketToken(redis) : undefined + + buildPlayerAuthActivity({ + em, + player: alias.player, + type: PlayerAuthActivityType.LOGGED_IN, + ip, + userAgent, + }) + + await em.flush() + + return { + status: 200, + body: { alias, sessionToken, socketToken }, + } +} + export const verifyRoute = apiRoute({ method: 'post', path: '/verify', @@ -23,64 +95,21 @@ export const verifyRoute = apiRoute({ const { aliasId, code } = ctx.state.validated.body const em = ctx.em - const key = ctx.state.key - const alias = await em.repo(PlayerAlias).findOne( { id: aliasId, - player: { - game: ctx.state.game, - }, - }, - { - populate: ['player.auth'], + player: { game: ctx.state.game }, }, + { populate: ['player.auth'] }, ) - if (!alias) { - return ctx.throw(403, { - message: 'Player alias not found', - errorCode: 'VERIFICATION_ALIAS_NOT_FOUND', - }) - } - - if (!alias.player.auth) { - return ctx.throw(400, 'Player does not have authentication') - } - - const redis = ctx.redis - const redisCode = await redis.get(getRedisAuthKey(key, alias)) - - if (!redisCode || code !== redisCode) { - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.VERIFICATION_FAILED, - }) - await em.flush() - - return ctx.throw(403, { - message: 'Invalid code', - errorCode: 'VERIFICATION_CODE_INVALID', - }) - } - - await redis.del(getRedisAuthKey(key, alias)) - - const sessionToken = await alias.player.auth.createSession(alias) - const socketToken = await alias.createSocketToken(redis) - - createPlayerAuthActivity(ctx, alias.player, { - type: PlayerAuthActivityType.LOGGED_IN, + return verifyHandler({ + em, + alias, + code, + redis: ctx.redis, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], }) - - await em.flush() - - return { - status: 200, - body: { - alias, - sessionToken, - socketToken, - }, - } }, }) diff --git a/src/routes/protected/game/settings.ts b/src/routes/protected/game/settings.ts index 84c2a0cc..e38cb12c 100644 --- a/src/routes/protected/game/settings.ts +++ b/src/routes/protected/game/settings.ts @@ -18,6 +18,7 @@ export const settingsRoute = protectedRoute({ purgeDevPlayersRetention: game.purgeDevPlayersRetention, purgeLivePlayersRetention: game.purgeLivePlayersRetention, website: game.website, + gameToken: game.getToken(), }, }, } diff --git a/src/routes/public/player-public/common.ts b/src/routes/public/player-public/common.ts new file mode 100644 index 00000000..b3571c88 --- /dev/null +++ b/src/routes/public/player-public/common.ts @@ -0,0 +1,42 @@ +import type { Next } from 'koa' +import type { PublicRouteContext } from '../../../lib/routing/context' +import Game from '../../../entities/game' +import PlayerAlias from '../../../entities/player-alias' +import { sign, verify } from '../../../lib/auth/jwt' + +const PUBLIC_SESSION_AUDIENCE = 'player-public' + +export type PlayerPublicRouteState = { + game: Game +} + +export async function loadGameFromToken( + ctx: PublicRouteContext, + next: Next, +) { + const game = await Game.fromToken(ctx.params.token, ctx.em) + if (!game) { + return ctx.throw(404, 'Game not found') + } + + ctx.state.game = game + + await next() +} + +export async function buildPublicPlayerSession(alias: PlayerAlias) { + return await sign({ playerId: alias.player.id, aliasId: alias.id }, process.env.JWT_SECRET!, { + expiresIn: '5m', + audience: PUBLIC_SESSION_AUDIENCE, + }) +} + +export async function verifyPublicPlayerSession(token: string) { + try { + return await verify<{ playerId: string; aliasId: number }>(token, process.env.JWT_SECRET!, { + audience: PUBLIC_SESSION_AUDIENCE, + }) + } catch { + return null + } +} diff --git a/src/routes/public/player-public/delete.ts b/src/routes/public/player-public/delete.ts new file mode 100644 index 00000000..e945c1c7 --- /dev/null +++ b/src/routes/public/player-public/delete.ts @@ -0,0 +1,49 @@ +import PlayerAlias from '../../../entities/player-alias' +import { publicRoute, withMiddleware } from '../../../lib/routing/router' +import { sessionHeaderSchema } from '../../../lib/validation/sessionHeaderSchema' +import { throwInvalidSessionError } from '../../../middleware/player-auth-middleware' +import { performDelete } from '../../api/player-auth/delete' +import { loadGameFromToken, verifyPublicPlayerSession } from './common' + +export const deleteRoute = publicRoute({ + method: 'delete', + schema: (z) => ({ + body: z.object({ + sessionToken: sessionHeaderSchema, + }), + }), + middleware: withMiddleware(loadGameFromToken), + handler: async (ctx) => { + const { sessionToken } = ctx.state.validated.body + const { game } = ctx.state + const em = ctx.em + + const session = await verifyPublicPlayerSession(sessionToken) + if (!session) { + return throwInvalidSessionError(ctx) + } + + const alias = await em.repo(PlayerAlias).findOne( + { + id: session.aliasId, + player: { id: session.playerId, game }, + }, + { populate: ['player.auth'] }, + ) + + if (!alias) { + return throwInvalidSessionError(ctx) + } + + if (!alias.player.auth) { + return ctx.throw(400, 'Player does not have authentication') + } + + return performDelete({ + em, + alias, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], + }) + }, +}) diff --git a/src/routes/public/player-public/game.ts b/src/routes/public/player-public/game.ts new file mode 100644 index 00000000..9cbd0c89 --- /dev/null +++ b/src/routes/public/player-public/game.ts @@ -0,0 +1,20 @@ +import { publicRoute, withMiddleware } from '../../../lib/routing/router' +import { loadGameFromToken } from './common' + +export const gameRoute = publicRoute({ + method: 'get', + path: '/game', + middleware: withMiddleware(loadGameFromToken), + handler: async (ctx) => { + const { game } = ctx.state + + return { + status: 200, + body: { + game: { + name: game.name, + }, + }, + } + }, +}) diff --git a/src/routes/public/player-public/index.ts b/src/routes/public/player-public/index.ts new file mode 100644 index 00000000..2249c9a3 --- /dev/null +++ b/src/routes/public/player-public/index.ts @@ -0,0 +1,14 @@ +import { publicRouter } from '../../../lib/routing/router' +import { deleteRoute } from './delete' +import { gameRoute } from './game' +import { loginRoute } from './login' +import { verifyRoute } from './verify' + +export function playerPublicRouter() { + return publicRouter('/public/players/:token', ({ route }) => { + route(gameRoute) + route(loginRoute) + route(verifyRoute) + route(deleteRoute) + }) +} diff --git a/src/routes/public/player-public/login.ts b/src/routes/public/player-public/login.ts new file mode 100644 index 00000000..dd574e39 --- /dev/null +++ b/src/routes/public/player-public/login.ts @@ -0,0 +1,38 @@ +import PlayerAlias, { PlayerAliasService } from '../../../entities/player-alias' +import { publicRoute, withMiddleware } from '../../../lib/routing/router' +import { loginHandler } from '../../api/player-auth/login' +import { buildPublicPlayerSession, loadGameFromToken } from './common' + +export const loginRoute = publicRoute({ + method: 'post', + path: '/login', + schema: (z) => ({ + body: z.object({ + identifier: z.string(), + password: z.string(), + }), + }), + middleware: withMiddleware(loadGameFromToken), + handler: async (ctx) => { + const { identifier, password } = ctx.state.validated.body + const { game } = ctx.state + const em = ctx.em + + const alias = await em.repo(PlayerAlias).findOne({ + service: PlayerAliasService.TALO, + identifier: identifier.trim(), + player: { game }, + }) + + return loginHandler({ + em, + alias, + password, + redis: ctx.redis, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], + sessionBuilder: buildPublicPlayerSession, + createSocketToken: false, + }) + }, +}) diff --git a/src/routes/public/player-public/verify.ts b/src/routes/public/player-public/verify.ts new file mode 100644 index 00000000..f9ba4bdf --- /dev/null +++ b/src/routes/public/player-public/verify.ts @@ -0,0 +1,40 @@ +import PlayerAlias from '../../../entities/player-alias' +import { publicRoute, withMiddleware } from '../../../lib/routing/router' +import { verifyHandler } from '../../api/player-auth/verify' +import { buildPublicPlayerSession, loadGameFromToken } from './common' + +export const verifyRoute = publicRoute({ + method: 'post', + path: '/verify', + schema: (z) => ({ + body: z.object({ + aliasId: z.number(), + code: z.string(), + }), + }), + middleware: withMiddleware(loadGameFromToken), + handler: async (ctx) => { + const { aliasId, code } = ctx.state.validated.body + const { game } = ctx.state + const em = ctx.em + + const alias = await em.repo(PlayerAlias).findOne( + { + id: aliasId, + player: { game }, + }, + { populate: ['player.auth'] }, + ) + + return verifyHandler({ + em, + alias, + code, + redis: ctx.redis, + ip: ctx.request.ip, + userAgent: ctx.request.headers['user-agent'], + sessionBuilder: buildPublicPlayerSession, + createSocketToken: false, + }) + }, +}) diff --git a/tests/routes/protected/game/settings.test.ts b/tests/routes/protected/game/settings.test.ts index ef85cf1a..39d265ed 100644 --- a/tests/routes/protected/game/settings.test.ts +++ b/tests/routes/protected/game/settings.test.ts @@ -30,6 +30,7 @@ describe('Game - settings', () => { purgeDevPlayersRetention: 30, purgeLivePlayersRetention: 60, website: 'https://example.com', + gameToken: expect.any(String), }) } }, diff --git a/tests/routes/public/player-public/delete.test.ts b/tests/routes/public/player-public/delete.test.ts new file mode 100644 index 00000000..784d6403 --- /dev/null +++ b/tests/routes/public/player-public/delete.test.ts @@ -0,0 +1,159 @@ +import bcrypt from 'bcrypt' +import assert from 'node:assert' +import request from 'supertest' +import PlayerAuthActivity, { + PlayerAuthActivityType, +} from '../../../../src/entities/player-auth-activity' +import { buildPublicPlayerSession } from '../../../../src/routes/public/player-public/common' +import * as deletePlayers from '../../../../src/tasks/deletePlayers' +import PlayerAuthActivityFactory from '../../../fixtures/PlayerAuthActivityFactory' +import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import createOrganisationAndGame from '../../../utils/createOrganisationAndGame' + +describe('Player public - delete', { timeout: 30_000 }, () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should delete the account if the current password is correct', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + })) + .one(), + })) + .one() + const alias = player.aliases[0] + const activities = await new PlayerAuthActivityFactory(game).state(() => ({ player })).many(10) + await em.persist([player, ...activities]).flush() + + const sessionToken = await buildPublicPlayerSession(alias) + const prevIdentifier = alias.identifier + + await request(app) + .delete(`/public/players/${game.getToken()}`) + .send({ sessionToken }) + .expect(204) + + const updatedPlayer = await em.refreshOrFail(player, { populate: ['aliases', 'auth'] }) + expect(updatedPlayer.aliases).toHaveLength(0) + expect(updatedPlayer.auth).toBeNull() + + expect(await em.refresh(alias)).toBeNull() + + const activity = await em.getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.DELETED_AUTH, + player: player.id, + extra: { identifier: prevIdentifier }, + }) + assert(activity) + expect(activity.extra.ip).toBeUndefined() + + const activityCount = await em.getRepository(PlayerAuthActivity).count({ player: player.id }) + expect(activityCount).toBe(1) + }) + + it('should return 404 for an invalid game token', async () => { + await request(app).delete('/public/players/badtoken').send({ sessionToken: 'fake' }).expect(404) + }) + + it('should return 401 for an invalid session', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + })) + .one(), + })) + .one() + await em.persist(player).flush() + + const res = await request(app) + .delete(`/public/players/${game.getToken()}`) + .send({ sessionToken: 'invalid-session-token' }) + .expect(401) + + expect(res.body.errorCode).toBe('INVALID_SESSION') + }) + + it('should return 400 if the player does not have authentication', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]).one() + await em.persist(player).flush() + + const alias = player.aliases[0] + const sessionToken = await buildPublicPlayerSession(alias) + + const res = await request(app) + .delete(`/public/players/${game.getToken()}`) + .send({ sessionToken }) + .expect(400) + + expect(res.body).toStrictEqual({ message: 'Player does not have authentication' }) + }) + + it('should rollback if clickhouse fails', async () => { + vi.spyOn(deletePlayers, 'deleteClickHousePlayerData').mockRejectedValue(new Error()) + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const sessionToken = await buildPublicPlayerSession(alias) + + await request(app) + .delete(`/public/players/${game.getToken()}`) + .send({ sessionToken }) + .expect(500) + + expect(await em.refresh(alias)).not.toBeNull() + }) + + it('should not delete an account from a different game', async () => { + const [, game1] = await createOrganisationAndGame() + const [, game2] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game1]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const sessionToken = await buildPublicPlayerSession(alias) + + const res = await request(app) + .delete(`/public/players/${game2.getToken()}`) + .send({ sessionToken }) + .expect(401) + + expect(res.body.errorCode).toBe('INVALID_SESSION') + }) +}) diff --git a/tests/routes/public/player-public/game.test.ts b/tests/routes/public/player-public/game.test.ts new file mode 100644 index 00000000..40204871 --- /dev/null +++ b/tests/routes/public/player-public/game.test.ts @@ -0,0 +1,25 @@ +import { randNumber } from '@ngneat/falso' +import Sqids from 'sqids' +import request from 'supertest' +import createOrganisationAndGame from '../../../utils/createOrganisationAndGame' + +describe('Player public - game', () => { + it('should return the game name for a valid token', async () => { + const [, game] = await createOrganisationAndGame() + + const res = await request(app).get(`/public/players/${game.getToken()}/game`).expect(200) + + expect(res.body.game.name).toBe(game.name) + }) + + it('should return 404 for an invalid token', async () => { + await request(app).get('/public/players/invalidtoken/game').expect(404) + }) + + it('should return 404 for a malformed token', async () => { + const [, game] = await createOrganisationAndGame() + + const token = new Sqids({ minLength: 8 }).encode([game.id, randNumber()]) + await request(app).get(`/public/players/${token}/game`).expect(404) + }) +}) diff --git a/tests/routes/public/player-public/login.test.ts b/tests/routes/public/player-public/login.test.ts new file mode 100644 index 00000000..e6b0e2a7 --- /dev/null +++ b/tests/routes/public/player-public/login.test.ts @@ -0,0 +1,195 @@ +import bcrypt from 'bcrypt' +import request from 'supertest' +import PlayerAuthActivity, { + PlayerAuthActivityType, +} from '../../../../src/entities/player-auth-activity' +import * as sendEmail from '../../../../src/lib/messaging/sendEmail' +import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import createOrganisationAndGame from '../../../utils/createOrganisationAndGame' + +describe('Player public - login', () => { + const sendMock = vi.spyOn(sendEmail, 'default') + + afterEach(() => { + sendMock.mockClear() + }) + + it('should login a player with valid credentials', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: false, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/login`) + .send({ identifier: alias.identifier, password: 'password' }) + .expect(200) + + expect(res.body.alias.identifier).toBe(alias.identifier) + expect(res.body.alias.player.auth).toStrictEqual({ + email: 'boz@mail.com', + sessionCreatedAt: null, + verificationEnabled: false, + }) + expect(res.body.sessionToken).toBeTruthy() + + const activity = await em.getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.LOGGED_IN, + player: player.id, + }) + expect(activity).not.toBeNull() + }) + + it('should return 401 for an incorrect password', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + verificationEnabled: false, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/login`) + .send({ identifier: alias.identifier, password: 'wrongpassword' }) + .expect(401) + + expect(res.body).toStrictEqual({ + message: 'Incorrect identifier or password', + errorCode: 'INVALID_CREDENTIALS', + }) + }) + + it('should return 401 for an unknown identifier', async () => { + const [, game] = await createOrganisationAndGame() + await em.persist(game).flush() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/login`) + .send({ identifier: 'nobody', password: 'password' }) + .expect(401) + + expect(res.body).toStrictEqual({ + message: 'Incorrect identifier or password', + errorCode: 'INVALID_CREDENTIALS', + }) + }) + + it('should return 404 for an invalid game token', async () => { + await request(app) + .post('/public/players/invalidtoken/login') + .send({ identifier: 'someone', password: 'password' }) + .expect(404) + }) + + it('should send a verification code if verification is enabled', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: true, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/login`) + .send({ identifier: alias.identifier, password: 'password' }) + .expect(200) + + expect(res.body).toStrictEqual({ + aliasId: alias.id, + verificationRequired: true, + }) + + expect(await redis.get(`player-auth:${game.id}:verification:${alias.id}`)).toHaveLength(6) + expect(sendMock).toHaveBeenCalledOnce() + + const activity = await em.getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERIFICATION_STARTED, + player: player.id, + }) + expect(activity).not.toBeNull() + }) + + it('should trim identifiers with whitespace', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + verificationEnabled: false, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/login`) + .send({ identifier: ` ${alias.identifier} `, password: 'password' }) + .expect(200) + + expect(res.body.alias.identifier).toBe(alias.identifier) + expect(res.body.sessionToken).toBeTruthy() + }) + + it('should not login a player from a different game', async () => { + const [, game1] = await createOrganisationAndGame() + const [, game2] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game1]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + verificationEnabled: false, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + const res = await request(app) + .post(`/public/players/${game2.getToken()}/login`) + .send({ identifier: alias.identifier, password: 'password' }) + .expect(401) + + expect(res.body.errorCode).toBe('INVALID_CREDENTIALS') + }) +}) diff --git a/tests/routes/public/player-public/verify.test.ts b/tests/routes/public/player-public/verify.test.ts new file mode 100644 index 00000000..9d3181a4 --- /dev/null +++ b/tests/routes/public/player-public/verify.test.ts @@ -0,0 +1,149 @@ +import bcrypt from 'bcrypt' +import request from 'supertest' +import PlayerAuthActivity, { + PlayerAuthActivityType, +} from '../../../../src/entities/player-auth-activity' +import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import createOrganisationAndGame from '../../../utils/createOrganisationAndGame' + +describe('Player public - verify', () => { + it('should login a player if the verification code is correct', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: true, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + await redis.set(`player-auth:${game.id}:verification:${alias.id}`, '123456') + + const res = await request(app) + .post(`/public/players/${game.getToken()}/verify`) + .send({ aliasId: alias.id, code: '123456' }) + .expect(200) + + expect(res.body.alias.identifier).toBe(alias.identifier) + expect(res.body.alias.player.auth).toStrictEqual({ + email: 'boz@mail.com', + sessionCreatedAt: null, + verificationEnabled: true, + }) + expect(res.body.sessionToken).toBeTruthy() + + expect(await redis.get(`player-auth:${game.id}:verification:${alias.id}`)).toBeNull() + }) + + it('should return 404 for an invalid game token', async () => { + await request(app) + .post('/public/players/badtoken/verify') + .send({ aliasId: 1, code: '123456' }) + .expect(404) + }) + + it('should return 403 if the alias does not exist', async () => { + const [, game] = await createOrganisationAndGame() + + const res = await request(app) + .post(`/public/players/${game.getToken()}/verify`) + .send({ aliasId: Number.MAX_SAFE_INTEGER, code: '123456' }) + .expect(403) + + expect(res.body).toStrictEqual({ + message: 'Player alias not found', + errorCode: 'VERIFICATION_ALIAS_NOT_FOUND', + }) + }) + + it('should return 403 if the verification code is incorrect', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + verificationEnabled: true, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + await redis.set(`player-auth:${game.id}:verification:${alias.id}`, '123456') + + const res = await request(app) + .post(`/public/players/${game.getToken()}/verify`) + .send({ aliasId: alias.id, code: '111111' }) + .expect(403) + + expect(res.body).toStrictEqual({ + message: 'Invalid code', + errorCode: 'VERIFICATION_CODE_INVALID', + }) + + expect(await redis.get(`player-auth:${game.id}:verification:${alias.id}`)).toBe('123456') + + const activity = await em.getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERIFICATION_FAILED, + player: player.id, + }) + expect(activity).not.toBeNull() + }) + + it('should return 400 if the player does not have authentication', async () => { + const [, game] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game]).one() + await em.persist(player).flush() + + const alias = player.aliases[0] + + const res = await request(app) + .post(`/public/players/${game.getToken()}/verify`) + .send({ aliasId: alias.id, code: '123456' }) + .expect(400) + + expect(res.body).toStrictEqual({ message: 'Player does not have authentication' }) + }) + + it('should not verify an alias from a different game', async () => { + const [, game1] = await createOrganisationAndGame() + const [, game2] = await createOrganisationAndGame() + + const player = await new PlayerFactory([game1]) + .withTaloAlias() + .state(async () => ({ + auth: await new PlayerAuthFactory() + .state(async () => ({ + password: await bcrypt.hash('password', 10), + verificationEnabled: true, + })) + .one(), + })) + .one() + const alias = player.aliases[0] + await em.persist(player).flush() + + await redis.set(`player-auth:${game1.id}:verification:${alias.id}`, '123456') + + const res = await request(app) + .post(`/public/players/${game2.getToken()}/verify`) + .send({ aliasId: alias.id, code: '123456' }) + .expect(403) + + expect(res.body.errorCode).toBe('VERIFICATION_ALIAS_NOT_FOUND') + }) +}) From c5355a02be67f29f3635d4b85fb66927251a7adb Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:03:29 +0000 Subject: [PATCH 2/3] add rate limit for public player routes --- src/middleware/limiter-middleware.ts | 16 ++++++++++------ tests/middleware/getMaxRequestsForPath.test.ts | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/middleware/limiter-middleware.ts b/src/middleware/limiter-middleware.ts index 19b40641..a4beacbc 100644 --- a/src/middleware/limiter-middleware.ts +++ b/src/middleware/limiter-middleware.ts @@ -5,9 +5,11 @@ import { isAPIRoute } from '../lib/routing/route-info' const limitMap = { default: Number(process.env.API_RATE_LIMIT) || 100, auth: Number(process.env.API_RATE_LIMIT_AUTH) || 20, + playerPublic: Number(process.env.PUBLIC_RATE_LIMIT_PLAYERS) || 10, } as const const rateLimitOverrides = new Map([ + ['/public/players', 'playerPublic'], ['/v1/players/auth', 'auth'], ['/v1/players/identify', 'auth'], ['/v1/players/socket-token', 'auth'], @@ -25,12 +27,14 @@ export function getMaxRequestsForPath(requestPath: string) { } } -export async function limiterMiddleware(ctx: Context, next: Next): Promise { - if ( - isAPIRoute(ctx) && - process.env.NODE_ENV !== 'test' && - !rateLimitBypass.has(ctx.request.path) - ) { +function isPlayerPublicRoute(ctx: Context) { + return ctx.path.match(/^\/(public\/players)\//) !== null +} + +export async function limiterMiddleware(ctx: Context, next: Next) { + const routeMatches = isPlayerPublicRoute(ctx) || isAPIRoute(ctx) + + if (routeMatches && process.env.NODE_ENV !== 'test' && !rateLimitBypass.has(ctx.request.path)) { const { limitMapKey, maxRequests } = getMaxRequestsForPath(ctx.request.path) const userId = ctx.state.jwt?.sub || 'anonymous' const redisKey = `requests:${userId}:${ctx.request.ip}:${limitMapKey}` diff --git a/tests/middleware/getMaxRequestsForPath.test.ts b/tests/middleware/getMaxRequestsForPath.test.ts index 545ad1e8..f30bfdb1 100644 --- a/tests/middleware/getMaxRequestsForPath.test.ts +++ b/tests/middleware/getMaxRequestsForPath.test.ts @@ -2,6 +2,7 @@ import { getMaxRequestsForPath } from '../../src/middleware/limiter-middleware' describe('getMaxRequestsForPath', () => { it.each([ + ['playerPublic', 10, '/public/players'], ['auth', 20, '/v1/players/auth'], ['auth', 20, '/v1/players/identify'], ['auth', 20, '/v1/socket-tickets'], From 7e3fac9d80398dc9aa5b6a5f9eaf256fccd7bb72 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:10:05 +0000 Subject: [PATCH 3/3] delete auth activity inside transaction --- src/routes/api/player-auth/delete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/api/player-auth/delete.ts b/src/routes/api/player-auth/delete.ts index 66bea773..2915b155 100644 --- a/src/routes/api/player-auth/delete.ts +++ b/src/routes/api/player-auth/delete.ts @@ -26,9 +26,9 @@ export async function performDelete({ ip: string userAgent?: string }) { - await em.repo(PlayerAuthActivity).nativeDelete({ player: alias.player }) - await em.transactional(async (trx) => { + await em.repo(PlayerAuthActivity).nativeDelete({ player: alias.player }) + buildPlayerAuthActivity({ em: trx, player: alias.player,