Skip to content

Commit 9f6b1c4

Browse files
authored
Merge pull request #338 from TaloDev/steamworks-auth
Allow players to authenticate with Steamworks tickets
2 parents 50b783d + 691f000 commit 9f6b1c4

File tree

9 files changed

+435
-20
lines changed

9 files changed

+435
-20
lines changed

src/docs/player-api.docs.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const PlayerAPIDocs: APIDocs<PlayerAPIService> = {
1616
sample: {
1717
alias: {
1818
id: 1,
19-
service: 'steam',
20-
identifier: '11133645',
19+
service: 'username',
20+
identifier: 'jimbo',
2121
player: {
2222
id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9',
2323
props: [
@@ -38,6 +38,20 @@ const PlayerAPIDocs: APIDocs<PlayerAPIService> = {
3838
}
3939
}
4040
}
41+
},
42+
{
43+
title: 'Steam authentication with identity (identifier format is <identity>:<ticket>)',
44+
sample: {
45+
service: 'steam',
46+
identifier: 'talo:14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710'
47+
}
48+
},
49+
{
50+
title: 'Steam authentication without identity',
51+
sample: {
52+
service: 'steam',
53+
identifier: '14000000bc9f006804c54b4032b27d0502002002cbfdcf771800000002000000060000004f0957cde6f88aecb090245624000000d8000000480000000500000033b19c0602002002fab015006438f58d8001b9d0000000008c57ef77fce61b780200551002000200f1cf060000000000d4dff043aed3c37739e65db7bc83d0196ecabeed867436df9cafa957ba08e29fe20739e47a3142ef1181e1fae857105545049f2bb6a6e86594fbf675246b5618b297d6535b605160f51650e61f516f05ed62163f5a0616c56c4fcbed3c049d7eedd65e69f23b843d8f92939b6987f9fc6980107079710'
54+
}
4155
}
4256
]
4357
},

src/entities/integration.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Entity, EntityManager, Enum, Filter, ManyToOne, PrimaryKey, Property }
22
import { Request, Required, ValidationCondition } from 'koa-clay'
33
import { decrypt, encrypt } from '../lib/crypto/string-encryption'
44
import Game from './game'
5-
import { createSteamworksLeaderboard, createSteamworksLeaderboardEntry, deleteSteamworksLeaderboard, deleteSteamworksLeaderboardEntry, setSteamworksStat, syncSteamworksLeaderboards, syncSteamworksStats } from '../lib/integrations/steamworks-integration'
5+
import { authenticateTicket, createSteamworksLeaderboard, createSteamworksLeaderboardEntry, deleteSteamworksLeaderboard, deleteSteamworksLeaderboardEntry, setSteamworksStat, syncSteamworksLeaderboards, syncSteamworksStats } from '../lib/integrations/steamworks-integration'
66
import Leaderboard from './leaderboard'
77
import { pick } from 'lodash'
88
import LeaderboardEntry from './leaderboard-entry'
@@ -176,6 +176,13 @@ export default class Integration {
176176
}
177177
}
178178

179+
async getPlayerIdentifier(req: Request, identifier: string): Promise<string> {
180+
switch (this.type) {
181+
case IntegrationType.STEAMWORKS:
182+
return authenticateTicket(req, this, identifier)
183+
}
184+
}
185+
179186
toJSON() {
180187
return {
181188
id: this.id,

src/entities/player.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ export default class Player {
4747
this.props.add(new PlayerProp(this, key, value))
4848
}
4949

50+
upsertProp(key: string, value: string) {
51+
const prop = this.props.getItems().find((prop) => prop.key === key)
52+
53+
if (prop) {
54+
prop.value = value
55+
} else {
56+
this.addProp(key, value)
57+
}
58+
}
59+
5060
setProps(props: { key: string, value: string }[]) {
5161
this.props.set(props.map(({ key, value }) => new PlayerProp(this, key, value)))
5262
}

src/lib/integrations/steamworks-integration.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Player from '../../entities/player'
1111
import { performance } from 'perf_hooks'
1212
import GameStat from '../../entities/game-stat'
1313
import PlayerGameStat from '../../entities/player-game-stat'
14+
import { Request } from 'koa-clay'
1415

1516
type SteamworksRequestConfig = {
1617
method: SteamworksRequestMethod
@@ -101,6 +102,35 @@ export type GetUserStatsForGameResponse = {
101102
}
102103
}
103104

105+
export type AuthenticateUserTicketResponse = {
106+
response: {
107+
params?: {
108+
result: 'OK'
109+
steamid: string
110+
ownersteamid: string
111+
vacbanned: boolean
112+
publisherbanned: boolean
113+
}
114+
error?: {
115+
errorcode: number
116+
errordesc: string
117+
}
118+
}
119+
}
120+
121+
export type CheckAppOwnershipResponse = {
122+
appownership: {
123+
ownsapp: boolean
124+
permanent: boolean
125+
timestamp: string
126+
ownersteamid: string
127+
sitelicense: boolean
128+
timedtrial: boolean
129+
usercanceled: boolean
130+
result: 'OK'
131+
}
132+
}
133+
104134
function createSteamworksRequestConfig(integration: Integration, method: SteamworksRequestMethod, url: string, body = ''): SteamworksRequestConfig {
105135
return {
106136
method,
@@ -465,3 +495,68 @@ export async function syncSteamworksStats(em: EntityManager, integration: Integr
465495
await setSteamworksStat(em, integration, unsyncedPlayerStat, steamAlias)
466496
}
467497
}
498+
499+
export async function authenticateTicket(req: Request, integration: Integration, identifier: string): Promise<string> {
500+
const em: EntityManager = req.ctx.em
501+
502+
const parts = identifier.split(':')
503+
const identity = parts.length > 1 ? parts[0] : undefined
504+
const ticket = parts.at(-1)
505+
506+
const config = createSteamworksRequestConfig(integration, 'GET', `/ISteamUserAuth/AuthenticateUserTicket/v1?appid=${integration.getConfig().appId}&ticket=${ticket}${identity ? `&identity=${identity}` : ''}`)
507+
const event = createSteamworksIntegrationEvent(integration, config)
508+
const res = await makeRequest<AuthenticateUserTicketResponse>(config, event)
509+
await em.persistAndFlush(event)
510+
511+
if (res.data.response.error) {
512+
const message = `Failed to authenticate Steamworks ticket: ${res.data.response.error.errordesc} (${res.data.response.error.errorcode})`
513+
throw new Error(message, { cause: 400 })
514+
}
515+
516+
const steamId = res.data.response.params.steamid
517+
const alias = await em.getRepository(PlayerAlias).findOne({
518+
service: PlayerAliasService.STEAM,
519+
identifier: steamId,
520+
player: {
521+
game: integration.game
522+
}
523+
})
524+
525+
const {
526+
appownership: {
527+
ownsapp,
528+
permanent,
529+
timestamp
530+
}
531+
} = await verifyOwnership(em, integration, steamId)
532+
533+
const { vacbanned, publisherbanned } = res.data.response.params
534+
535+
if (alias) {
536+
alias.player.upsertProp('META_STEAMWORKS_VAC_BANNED', String(vacbanned))
537+
alias.player.upsertProp('META_STEAMWORKS_PUBLISHER_BANNED', String(publisherbanned))
538+
alias.player.upsertProp('META_STEAMWORKS_OWNS_APP', String(ownsapp))
539+
alias.player.upsertProp('META_STEAMWORKS_OWNS_APP_PERMANENTLY', String(permanent))
540+
alias.player.upsertProp('META_STEAMWORKS_OWNS_APP_FROM_DATE', timestamp)
541+
await em.flush()
542+
} else {
543+
req.ctx.state.initialPlayerProps = [
544+
{ key: 'META_STEAMWORKS_VAC_BANNED', value: String(vacbanned) },
545+
{ key: 'META_STEAMWORKS_PUBLISHER_BANNED', value: String(publisherbanned) },
546+
{ key: 'META_STEAMWORKS_OWNS_APP', value: String(ownsapp) },
547+
{ key: 'META_STEAMWORKS_OWNS_APP_PERMANENTLY', value: String(permanent) },
548+
{ key: 'META_STEAMWORKS_OWNS_APP_FROM_DATE', value: timestamp }
549+
]
550+
}
551+
552+
return steamId
553+
}
554+
555+
export async function verifyOwnership(em: EntityManager, integration: Integration, steamId: string): Promise<CheckAppOwnershipResponse> {
556+
const config = createSteamworksRequestConfig(integration, 'GET', `/ISteamUser/CheckAppOwnership/v3?appid=${integration.getConfig().appId}&steamid=${steamId}`)
557+
const event = createSteamworksIntegrationEvent(integration, config)
558+
const res = await makeRequest<CheckAppOwnershipResponse>(config, event)
559+
await em.persistAndFlush(event)
560+
561+
return res.data
562+
}

src/services/api/player-api.service.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,37 @@ import PlayerAPIDocs from '../../docs/player-api.docs'
1111
import PlayerProp from '../../entities/player-prop'
1212
import PlayerGameStat from '../../entities/player-game-stat'
1313
import checkScope from '../../policies/checkScope'
14+
import Integration, { IntegrationType } from '../../entities/integration'
1415

15-
export function findAliasFromIdentifyRequest(
16-
em: EntityManager,
16+
async function getRealIdentifier(
17+
req: Request,
18+
key: APIKey,
19+
service: string,
20+
identifier: string
21+
): Promise<string> {
22+
if (service === PlayerAliasService.STEAM) {
23+
const integration = await (req.ctx.em as EntityManager).getRepository(Integration).findOne({
24+
game: key.game,
25+
type: IntegrationType.STEAMWORKS
26+
})
27+
28+
if (integration) {
29+
return integration.getPlayerIdentifier(req, identifier)
30+
}
31+
}
32+
33+
return identifier
34+
}
35+
36+
export async function findAliasFromIdentifyRequest(
37+
req: Request,
1738
key: APIKey,
1839
service: string,
1940
identifier: string
2041
): Promise<PlayerAlias | null> {
21-
return em.getRepository(PlayerAlias).findOne({
42+
return (req.ctx.em as EntityManager).getRepository(PlayerAlias).findOne({
2243
service,
23-
identifier,
44+
identifier: await getRealIdentifier(req, key, service, identifier),
2445
player: {
2546
game: key.game
2647
}
@@ -38,7 +59,8 @@ export async function createPlayerFromIdentifyRequest(
3859
if (checkScope(key, APIKeyScope.WRITE_PLAYERS)) {
3960
const res = await forwardRequest(req, {
4061
body: {
41-
aliases: [{ service, identifier }]
62+
aliases: [{ service, identifier: await getRealIdentifier(req, key, service, identifier) }],
63+
props: req.ctx.state.initialPlayerProps
4264
}
4365
})
4466

@@ -77,14 +99,23 @@ export default class PlayerAPIService extends APIService {
7799
const em: EntityManager = req.ctx.em
78100

79101
const key = await this.getAPIKey(req.ctx)
80-
81-
let alias = await findAliasFromIdentifyRequest(em, key, service, identifier)
82-
if (!alias) {
83-
if (service === PlayerAliasService.TALO) {
84-
req.ctx.throw(404, 'Player not found: Talo aliases must be created using the /v1/players/auth API')
102+
let alias: PlayerAlias = null
103+
104+
try {
105+
alias = await findAliasFromIdentifyRequest(req, key, service, identifier)
106+
if (!alias) {
107+
if (service === PlayerAliasService.TALO) {
108+
req.ctx.throw(404, 'Player not found: Talo aliases must be created using the /v1/players/auth API')
109+
} else {
110+
const player = await createPlayerFromIdentifyRequest(req, key, service, identifier)
111+
alias = player?.aliases[0]
112+
}
113+
}
114+
} catch (err) {
115+
if (err instanceof Error && err.cause === 400) {
116+
req.ctx.throw(400, err.message)
85117
} else {
86-
const player = await createPlayerFromIdentifyRequest(req, key, service, identifier)
87-
alias = player?.aliases[0]
118+
throw err
88119
}
89120
}
90121

src/services/api/player-auth-api.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ export default class PlayerAuthAPIService extends APIService {
151151

152152
const key = await this.getAPIKey(req.ctx)
153153

154-
const alias = await findAliasFromIdentifyRequest(em, key, PlayerAliasService.TALO, identifier)
154+
const alias = await findAliasFromIdentifyRequest(req, key, PlayerAliasService.TALO, identifier)
155155
if (!alias) this.handleFailedLogin(req)
156156

157157
const passwordMatches = await bcrypt.compare(password, alias.player.auth.password)

tests/fixtures/PlayerFactory.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ export default class PlayerFactory extends Factory<Player> {
7676
})
7777
}
7878

79-
withSteamAlias(): this {
79+
withSteamAlias(steamId?: string): this {
8080
return this.state(async (player: Player) => {
81-
const alias = await new PlayerAliasFactory(player).steam().one()
81+
const alias = await new PlayerAliasFactory(player).steam().state(() => ({
82+
identifier: steamId ?? casual.integer(100000, 1000000).toString()
83+
})).one()
8284

8385
return {
8486
aliases: new Collection<PlayerAlias>(player, [alias])

tests/services/_api/game-stat-api/steamworksPut.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,5 @@ describe('Game stats API service - put - steamworks integration', () => {
8080

8181
const event = await (<EntityManager>global.em).getRepository(SteamworksIntegrationEvent).findOne({ integration })
8282
expect(event).toBeNull()
83-
84-
axiosMock.reset()
8583
})
8684
})

0 commit comments

Comments
 (0)