diff --git a/package-lock.json b/package-lock.json index c208bc24..9c935cc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.33.0", + "version": "0.34.0", "license": "MIT", "dependencies": { "@dinero.js/currencies": "^2.0.0-alpha.14", diff --git a/package.json b/package.json index 23f1d7d0..e7dcf61b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.33.0", + "version": "0.34.0", "description": "", "main": "src/index.ts", "scripts": { diff --git a/src/entities/api-key.ts b/src/entities/api-key.ts index ca7eefe6..ee34a6f3 100644 --- a/src/entities/api-key.ts +++ b/src/entities/api-key.ts @@ -37,6 +37,9 @@ export default class APIKey { @Property() createdAt: Date = new Date() + @Property({ onUpdate: () => new Date() }) + updatedAt?: Date = new Date() + @Property({ nullable: true }) revokedAt?: Date diff --git a/src/entities/game-activity.ts b/src/entities/game-activity.ts index c14ac1f0..c09347ae 100644 --- a/src/entities/game-activity.ts +++ b/src/entities/game-activity.ts @@ -29,7 +29,8 @@ export enum GameActivityType { GAME_PROPS_UPDATED, GAME_FEEDBACK_CATEGORY_CREATED, GAME_FEEDBACK_CATEGORY_UPDATED, - GAME_FEEDBACK_CATEGORY_DELETED + GAME_FEEDBACK_CATEGORY_DELETED, + API_KEY_UPDATED } @Entity() @@ -111,6 +112,14 @@ export default class GameActivity { return `${this.user.username} deleted the group ${this.extra.groupName}` case GameActivityType.GAME_PROPS_UPDATED: return `${this.user.username} updated the live config` + case GameActivityType.GAME_FEEDBACK_CATEGORY_CREATED: + return `${this.user.username} created the feedback category ${this.extra.feedbackCategoryInternalName}` + case GameActivityType.GAME_FEEDBACK_CATEGORY_UPDATED: + return `${this.user.username} updated the feedback category ${this.extra.feedbackCategoryInternalName}` + case GameActivityType.GAME_FEEDBACK_CATEGORY_DELETED: + return `${this.user.username} deleted the feedback category ${this.extra.feedbackCategoryInternalName}` + case GameActivityType.API_KEY_UPDATED: + return `${this.user.username} updated an access key` default: return '' } diff --git a/src/lib/integrations/steamworks-integration.ts b/src/lib/integrations/steamworks-integration.ts index 2398d8a3..0b3ba5e2 100644 --- a/src/lib/integrations/steamworks-integration.ts +++ b/src/lib/integrations/steamworks-integration.ts @@ -423,6 +423,7 @@ export async function syncSteamworksStats(em: EntityManager, integration: Integr for (const steamAlias of steamAliases) { const res = await getSteamworksStatsForPlayer(em, integration, steamAlias.identifier) const steamworksPlayerStats = res?.playerstats?.stats ?? [] + for (const steamworksPlayerStat of steamworksPlayerStats) { const stat = await em.getRepository(GameStat).findOne({ internalName: steamworksPlayerStat.name }) const existingPlayerStat = await em.getRepository(PlayerGameStat).findOne({ diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index e1eb8cbd..bbf32d5b 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -3450,6 +3450,16 @@ "length": 0, "mappedType": "datetime" }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 0, + "mappedType": "datetime" + }, "revoked_at": { "name": "revoked_at", "type": "datetime", diff --git a/src/migrations/20240614122547AddAPIKeyUpdatedAtColumn.ts b/src/migrations/20240614122547AddAPIKeyUpdatedAtColumn.ts new file mode 100644 index 00000000..cc1bf39d --- /dev/null +++ b/src/migrations/20240614122547AddAPIKeyUpdatedAtColumn.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations' + +export class AddAPIKeyUpdatedAtColumn extends Migration { + + async up(): Promise { + this.addSql('alter table `apikey` add `updated_at` datetime null;') + } + + async down(): Promise { + this.addSql('alter table `apikey` drop column `updated_at`;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index cb47f5f9..cb99ae5c 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -24,6 +24,7 @@ import { UpdateTableDefaultValues } from './20230205220923UpdateTableDefaultValu import { CreateGameSecretsTable } from './20230205220924CreateGameSecretsTable' import { AddAPIKeyLastUsedAtColumn } from './20230205220925AddAPIKeyLastUsedAtColumn' import { CreateGameFeedbackAndCategoryTables } from './20240606165637CreateGameFeedbackAndCategoryTables' +import { AddAPIKeyUpdatedAtColumn } from './20240614122547AddAPIKeyUpdatedAtColumn' export default [ { @@ -129,5 +130,9 @@ export default [ { name: 'CreateGameFeedbackAndCategoryTables', class: CreateGameFeedbackAndCategoryTables + }, + { + name: 'AddAPIKeyUpdatedAtColumn', + class: AddAPIKeyUpdatedAtColumn } ] diff --git a/src/policies/api-key.policy.ts b/src/policies/api-key.policy.ts index c3ceaaf9..027d5eca 100644 --- a/src/policies/api-key.policy.ts +++ b/src/policies/api-key.policy.ts @@ -19,6 +19,7 @@ export default class APIKeyPolicy extends Policy { } @UserTypeGate([UserType.ADMIN], 'revoke API keys') + @EmailConfirmedGate('revoke API keys') async delete(req: Request): Promise { const { id } = req.params this.ctx.state.apiKey = await this.em.getRepository(APIKey).findOne(Number(id)) @@ -26,4 +27,14 @@ export default class APIKeyPolicy extends Policy { return await this.canAccessGame((this.ctx.state.apiKey as APIKey).game.id) } + + @UserTypeGate([UserType.ADMIN], 'update API keys') + @EmailConfirmedGate('update API keys') + async put(req: Request): Promise { + const { id, gameId } = req.params + this.ctx.state.apiKey = await this.em.getRepository(APIKey).findOne(Number(id)) + if (!this.ctx.state.apiKey) return new PolicyDenial({ message: 'API key not found' }, 404) + + return await this.canAccessGame(Number(gameId)) + } } diff --git a/src/services/api-key.service.ts b/src/services/api-key.service.ts index ae583940..1e040f1b 100644 --- a/src/services/api-key.service.ts +++ b/src/services/api-key.service.ts @@ -11,7 +11,12 @@ import { GameActivityType } from '../entities/game-activity' export async function createToken(em: EntityManager, apiKey: APIKey): Promise { await em.populate(apiKey, ['game.apiSecret']) - const payload = { sub: apiKey.id, api: true } + const payload = { + sub: apiKey.id, + api: true, + iat: Math.floor(new Date(apiKey.createdAt).getTime() / 1000) + } + const token = await promisify(jwt.sign)(payload, apiKey.game.apiSecret.getPlainSecret()) return token } @@ -30,6 +35,9 @@ export async function createToken(em: EntityManager, apiKey: APIKey): Promise { const em: EntityManager = req.ctx.em - const apiKey = req.ctx.state.apiKey as APIKey // set in the policy + const apiKey = req.ctx.state.apiKey as APIKey apiKey.revokedAt = new Date() const token = await createToken(em, apiKey) @@ -120,4 +128,37 @@ export default class APIKeyService extends Service { } } } + + @Validate({ body: ['scopes'] }) + @HasPermission(APIKeyPolicy, 'put') + async put(req: Request): Promise { + const em: EntityManager = req.ctx.em + + const apiKey = req.ctx.state.apiKey as APIKey + apiKey.scopes = req.body.scopes + + const token = await createToken(em, apiKey) + + await createGameActivity(em, { + user: req.ctx.state.user, + game: req.ctx.state.game, + type: GameActivityType.API_KEY_UPDATED, + extra: { + keyId: apiKey.id, + display: { + 'Key ending in': token.substring(token.length - 5, token.length), + 'Scopes': apiKey.scopes.join(', ') + } + } + }) + + await em.flush() + + return { + status: 200, + body: { + apiKey + } + } + } } diff --git a/tests/services/api-key/delete.test.ts b/tests/services/api-key/delete.test.ts index f5609b7f..0fd421c3 100644 --- a/tests/services/api-key/delete.test.ts +++ b/tests/services/api-key/delete.test.ts @@ -69,6 +69,6 @@ describe('API key service - delete', () => { .auth(token, { type: 'bearer' }) .expect(403) - expect(res.body).toStrictEqual({ message: 'Forbidden' }) + expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to revoke API keys' }) }) }) diff --git a/tests/services/api-key/put.test.ts b/tests/services/api-key/put.test.ts new file mode 100644 index 00000000..5496200f --- /dev/null +++ b/tests/services/api-key/put.test.ts @@ -0,0 +1,109 @@ +import { EntityManager } from '@mikro-orm/mysql' +import request from 'supertest' +import { UserType } from '../../../src/entities/user' +import GameActivity, { GameActivityType } from '../../../src/entities/game-activity' +import createUserAndToken from '../../utils/createUserAndToken' +import userPermissionProvider from '../../utils/userPermissionProvider' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import APIKey from '../../../src/entities/api-key' + +describe('API key service - put', () => { + it.each(userPermissionProvider([ + UserType.ADMIN + ]))('should return a %i for a %s user', async (statusCode, _, type) => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({ type, emailConfirmed: true }, organisation) + + const key = new APIKey(game, user) + await (global.em).persistAndFlush(key) + + const res = await request(global.app) + .put(`/games/${game.id}/api-keys/${key.id}`) + .send({ scopes: ['read:players', 'write:events'] }) + .auth(token, { type: 'bearer' }) + .expect(statusCode) + + if (statusCode === 200) { + expect(res.body.apiKey.gameId).toBe(game.id) + expect(res.body.apiKey.scopes).toStrictEqual(['read:players', 'write:events']) + } + + const activity = await (global.em).getRepository(GameActivity).findOne({ + type: GameActivityType.API_KEY_UPDATED, + game, + extra: { + keyId: key.id, + display: { + 'Scopes': 'read:players, write:events' + } + } + }) + + if (statusCode === 200) { + expect(activity).not.toBeNull() + } else { + expect(activity).toBeNull() + } + }) + + it('should not update an api key if the user\'s email is not confirmed', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const key = new APIKey(game, user) + await (global.em).persistAndFlush(key) + + const res = await request(global.app) + .put(`/games/${game.id}/api-keys/${key.id}`) + .send({ scopes: ['read:players', 'write:events'] }) + .auth(token, { type: 'bearer' }) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'You need to confirm your email address to update API keys' }) + }) + + it('should not update an api key for a non-existent game', async () => { + const [, game] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN }) + + const key = new APIKey(game, user) + await (global.em).persistAndFlush(key) + + const res = await request(global.app) + .put(`/games/99999/api-keys/${key.id}`) + .send({ scopes: [] }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Game not found' }) + }) + + it('should not create an api key for a game the user has no access to', async () => { + const [, otherGame] = await createOrganisationAndGame() + const [token, user] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN }) + + const key = new APIKey(otherGame, user) + await (global.em).persistAndFlush(key) + + const res = await request(global.app) + .put(`/games/${otherGame.id}/api-keys/${key.id}`) + .send({ scopes: [] }) + .auth(token, { type: 'bearer' }) + .expect(403) + + expect(res.body).toStrictEqual({ message: 'Forbidden' }) + }) + + it('should not update an api key that does not exist', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ emailConfirmed: true, type: UserType.ADMIN }, organisation) + + const res = await request(global.app) + .put(`/games/${game.id}/api-keys/99999`) + .send({ scopes: ['read:players', 'write:events'] }) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'API key not found' }) + }) +})