diff --git a/package-lock.json b/package-lock.json index 28cbe124..d72f772a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.36.1", + "version": "0.37.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.36.1", + "version": "0.37.0", "license": "MIT", "dependencies": { "@dinero.js/currencies": "^2.0.0-alpha.14", @@ -1846,9 +1846,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.6", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", - "integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==", + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 063d40e2..14e18e71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.36.1", + "version": "0.37.0", "description": "", "main": "src/index.ts", "scripts": { diff --git a/src/docs/player-auth-api.docs.ts b/src/docs/player-auth-api.docs.ts index a87fe2d1..9907489d 100644 --- a/src/docs/player-auth-api.docs.ts +++ b/src/docs/player-auth-api.docs.ts @@ -237,6 +237,45 @@ const PlayerAuthAPIDocs: APIDocs = { } } ] + }, + toggleVerification: { + description: 'Toggle if verification is required for a player account', + params: { + headers: { + 'x-talo-player': 'The ID of the player', + 'x-talo-alias': 'The ID of the player\'s alias', + 'x-talo-session': 'The session token' + }, + body: { + currentPassword: 'The current password of the player', + verificationEnabled: 'The new verification status for the player account', + email: 'Required when attempting to enable verification if the player does not currently have an email address set' + } + }, + samples: [ + { + title: 'Sample request (disabling verification)', + sample: { + currentPassword: 'password', + verificationEnabled: false + } + }, + { + title: 'Sample request (enabling verification, player does not have an email address)', + sample: { + currentPassword: 'password', + email: 'boz@mail.com', + verificationEnabled: true + } + }, + { + title: 'Sample request (enabling verification, player has an email address)', + sample: { + currentPassword: 'password', + verificationEnabled: true + } + } + ] } } diff --git a/src/entities/index.ts b/src/entities/index.ts index de108522..8695d346 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,3 +1,4 @@ +import PlayerAuthActivity from './player-auth-activity' import PlayerAuth from './player-auth' import GameFeedback from './game-feedback' import Invite from './invite' @@ -33,6 +34,7 @@ import PlayerGroup from './player-group' import GameSecret from './game-secret' export default [ + PlayerAuthActivity, PlayerAuth, GameFeedback, GameSecret, diff --git a/src/entities/player-auth-activity.ts b/src/entities/player-auth-activity.ts new file mode 100644 index 00000000..f3bfecbf --- /dev/null +++ b/src/entities/player-auth-activity.ts @@ -0,0 +1,94 @@ +import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql' +import Player from './player' +import PlayerAlias, { PlayerAliasService } from './player-alias' + +export enum PlayerAuthActivityType { + REGISTERED, + VERIFICATION_STARTED, + VERIFICATION_FAILED, + LOGGED_IN, + LOGGED_OUT, + CHANGED_PASSWORD, + CHANGED_EMAIL, + PASSWORD_RESET_REQUESTED, + PASSWORD_RESET_COMPLETED, + VERFICIATION_TOGGLED, + CHANGE_PASSWORD_FAILED, + CHANGE_EMAIL_FAILED, + TOGGLE_VERIFICATION_FAILED +} + +@Entity() +export default class PlayerAuthActivity { + @PrimaryKey() + id: number + + @ManyToOne(() => Player, { eager: true }) + player: Player + + @Enum(() => PlayerAuthActivityType) + type: PlayerAuthActivityType + + @Property({ type: 'json' }) + extra: { + [key: string]: unknown + } = {} + + @Property() + createdAt: Date = new Date() + + constructor(player: Player) { + this.player = player + } + + private getAuthAlias(): PlayerAlias { + return this.player.aliases.find((alias) => alias.service === PlayerAliasService.TALO) + } + + /* v8 ignore start */ + private getActivity(): string { + const authAlias = this.getAuthAlias() + + switch (this.type) { + case PlayerAuthActivityType.REGISTERED: + return `${authAlias.identifier} created their account` + case PlayerAuthActivityType.VERIFICATION_STARTED: + return `${authAlias.identifier} started verification` + case PlayerAuthActivityType.VERIFICATION_FAILED: + return `${authAlias.identifier} failed verification` + case PlayerAuthActivityType.LOGGED_IN: + return `${authAlias.identifier} logged in` + case PlayerAuthActivityType.LOGGED_OUT: + return `${authAlias.identifier} logged out` + case PlayerAuthActivityType.CHANGED_PASSWORD: + return `${authAlias.identifier} changed their password` + case PlayerAuthActivityType.CHANGED_EMAIL: + return `${authAlias.identifier} changed their email` + case PlayerAuthActivityType.PASSWORD_RESET_REQUESTED: + return `A password reset request was made for ${authAlias.identifier}'s account` + case PlayerAuthActivityType.PASSWORD_RESET_COMPLETED: + return `A password reset was completed for ${authAlias.identifier}'s account` + case PlayerAuthActivityType.VERFICIATION_TOGGLED: + return `${authAlias.identifier} toggled verification` + case PlayerAuthActivityType.CHANGE_PASSWORD_FAILED: + return `${authAlias.identifier} failed to change their password` + case PlayerAuthActivityType.CHANGE_EMAIL_FAILED: + return `${authAlias.identifier} failed to change their email` + case PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED: + return `${authAlias.identifier} failed to toggle verification` + default: + return '' + } + } + /* v8 ignore stop */ + + toJSON() { + return { + id: this.id, + type: this.type, + description: this.getActivity(), + extra: this.extra, + createdAt: this.createdAt + } + } +} diff --git a/src/entities/player-auth.ts b/src/entities/player-auth.ts index 266c722f..6b4c3994 100644 --- a/src/entities/player-auth.ts +++ b/src/entities/player-auth.ts @@ -14,7 +14,8 @@ export enum PlayerAuthErrorCode { INVALID_SESSION = 'INVALID_SESSION', NEW_PASSWORD_MATCHES_CURRENT_PASSWORD = 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD', NEW_EMAIL_MATCHES_CURRENT_EMAIL = 'NEW_EMAIL_MATCHES_CURRENT_EMAIL', - PASSWORD_RESET_CODE_INVALID = 'PASSWORD_RESET_CODE_INVALID' + PASSWORD_RESET_CODE_INVALID = 'PASSWORD_RESET_CODE_INVALID', + VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED' } @Entity() diff --git a/src/lib/logging/createGameActivity.ts b/src/lib/logging/createGameActivity.ts index 23a99124..26f6d788 100644 --- a/src/lib/logging/createGameActivity.ts +++ b/src/lib/logging/createGameActivity.ts @@ -1,7 +1,7 @@ import { EntityManager } from '@mikro-orm/mysql' import GameActivity from '../../entities/game-activity' -export default async function createGameActivity(em: EntityManager, data: Partial): Promise { +export default function createGameActivity(em: EntityManager, data: Partial): GameActivity { const activity = new GameActivity(data.game, data.user) activity.type = data.type activity.extra = data.extra ?? {} diff --git a/src/lib/logging/createPlayerAuthActivity.ts b/src/lib/logging/createPlayerAuthActivity.ts new file mode 100644 index 00000000..021f498a --- /dev/null +++ b/src/lib/logging/createPlayerAuthActivity.ts @@ -0,0 +1,24 @@ +import PlayerAuthActivity from '../../entities/player-auth-activity' +import Player from '../../entities/player' +import { Request } from 'koa-clay' +import { EntityManager } from '@mikro-orm/mysql' + +export default function createPlayerAuthActivity( + req: Request, + player: Player, + data: Pick, 'type' | 'extra'> +): PlayerAuthActivity { + const em: EntityManager = req.ctx.em + + const activity = new PlayerAuthActivity(player) + activity.type = data.type + activity.extra = { + ...(data.extra ?? {}), + userAgent: req.headers['user-agent'], + ip: req.ctx.request.ip + } + + em.persist(activity) + + return activity +} diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index 0481d682..20190733 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -1049,6 +1049,95 @@ }, "nativeEnums": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "int", + "unsigned": true, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "player_id": { + "name": "player_id", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "type": { + "name": "type", + "type": "tinyint", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "enum" + }, + "extra": { + "name": "extra", + "type": "json", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 0, + "mappedType": "datetime" + } + }, + "name": "player_auth_activity", + "indexes": [ + { + "columnNames": [ + "player_id" + ], + "composite": false, + "keyName": "player_auth_activity_player_id_index", + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "PRIMARY", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "player_auth_activity_player_id_foreign": { + "constraintName": "player_auth_activity_player_id_foreign", + "columnNames": [ + "player_id" + ], + "localTableName": "player_auth_activity", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "player", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, { "columns": { "id": { diff --git a/src/migrations/20240725183402CreatePlayerAuthActivityTable.ts b/src/migrations/20240725183402CreatePlayerAuthActivityTable.ts new file mode 100644 index 00000000..0cd1fca6 --- /dev/null +++ b/src/migrations/20240725183402CreatePlayerAuthActivityTable.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations' + +export class CreatePlayerAuthActivityTable extends Migration { + + async up(): Promise { + this.addSql('create table `player_auth_activity` (`id` int unsigned not null auto_increment primary key, `player_id` varchar(255) not null, `type` tinyint not null, `extra` json not null, `created_at` datetime not null) default character set utf8mb4 engine = InnoDB;') + this.addSql('alter table `player_auth_activity` add index `player_auth_activity_player_id_index`(`player_id`);') + + this.addSql('alter table `player_auth_activity` add constraint `player_auth_activity_player_id_foreign` foreign key (`player_id`) references `player` (`id`) on update cascade;') + } + + async down(): Promise { + this.addSql('drop table if exists `player_auth_activity`;') + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 979966e6..c5bcaa9b 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -26,6 +26,7 @@ import { AddAPIKeyLastUsedAtColumn } from './20230205220925AddAPIKeyLastUsedAtCo import { CreateGameFeedbackAndCategoryTables } from './20240606165637CreateGameFeedbackAndCategoryTables' import { AddAPIKeyUpdatedAtColumn } from './20240614122547AddAPIKeyUpdatedAtColumn' import { CreatePlayerAuthTable } from './20240628155142CreatePlayerAuthTable' +import { CreatePlayerAuthActivityTable } from './20240725183402CreatePlayerAuthActivityTable' export default [ { @@ -139,5 +140,9 @@ export default [ { name: 'CreatePlayerAuthTable', class: CreatePlayerAuthTable + }, + { + name: 'CreatePlayerAuthActivityTable', + class: CreatePlayerAuthActivityTable } ] diff --git a/src/policies/api/player-auth-api.policy.ts b/src/policies/api/player-auth-api.policy.ts index 8553326c..733ab570 100644 --- a/src/policies/api/player-auth-api.policy.ts +++ b/src/policies/api/player-auth-api.policy.ts @@ -34,4 +34,8 @@ export default class PlayerAuthAPIPolicy extends Policy { async resetPassword(): Promise { return await this.hasScopes([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) } + + async toggleVerification(): Promise { + return await this.hasScopes([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + } } diff --git a/src/policies/player.policy.ts b/src/policies/player.policy.ts index 766f3087..632154a3 100644 --- a/src/policies/player.policy.ts +++ b/src/policies/player.policy.ts @@ -62,4 +62,14 @@ export default class PlayerPolicy extends Policy { return await this.canAccessGame(player.game.id) } + + @UserTypeGate([UserType.ADMIN], 'view player auth activities') + async getAuthActivities(req: Request): Promise { + const { id } = req.params + + const player = await this.getPlayer(id) + if (!player) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.canAccessGame(player.game.id) + } } diff --git a/src/services/api-key.service.ts b/src/services/api-key.service.ts index 1e040f1b..c883faff 100644 --- a/src/services/api-key.service.ts +++ b/src/services/api-key.service.ts @@ -50,7 +50,7 @@ export default class APIKeyService extends Service { const apiKey = new APIKey(req.ctx.state.game, req.ctx.state.user) apiKey.scopes = scopes - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.API_KEY_CREATED, @@ -97,7 +97,7 @@ export default class APIKeyService extends Service { const token = await createToken(em, apiKey) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.API_KEY_REVOKED, @@ -139,7 +139,7 @@ export default class APIKeyService extends Service { const token = await createToken(em, apiKey) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.API_KEY_UPDATED, diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts index bc924ecc..c8db8a11 100644 --- a/src/services/api/player-auth-api.service.ts +++ b/src/services/api/player-auth-api.service.ts @@ -13,6 +13,8 @@ import generateSixDigitCode from '../../lib/auth/generateSixDigitCode' import queueEmail from '../../lib/messaging/queueEmail' import PlayerAuthCode from '../../emails/player-auth-code-mail' import PlayerAuthResetPassword from '../../emails/player-auth-reset-password-mail' +import createPlayerAuthActivity from '../../lib/logging/createPlayerAuthActivity' +import { PlayerAuthActivityType } from '../../entities/player-auth-activity' @Routes([ { @@ -62,6 +64,12 @@ import PlayerAuthResetPassword from '../../emails/player-auth-reset-password-mai path: '/reset_password', handler: 'resetPassword', docs: PlayerAuthAPIDocs.resetPassword + }, + { + method: 'PATCH', + path: '/toggle_verification', + handler: 'toggleVerification', + docs: PlayerAuthAPIDocs.toggleVerification } ]) export default class PlayerAuthAPIService extends APIService { @@ -92,10 +100,18 @@ export default class PlayerAuthAPIService extends APIService { alias.player.auth = new PlayerAuth() alias.player.auth.password = await bcrypt.hash(password, 10) alias.player.auth.email = email || null - alias.player.auth.verificationEnabled = verificationEnabled + alias.player.auth.verificationEnabled = Boolean(verificationEnabled) em.persist(alias.player.auth) const sessionToken = await alias.player.auth.createSession(alias) + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.REGISTERED, + extra: { + verificationEnabled: alias.player.auth.verificationEnabled + } + }) + await em.flush() return { @@ -144,6 +160,12 @@ export default class PlayerAuthAPIService extends APIService { await redis.set(this.getRedisAuthKey(key, alias), code, 'EX', 300) await queueEmail(req.ctx.emailQueue, new PlayerAuthCode(alias, code)) + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.VERIFICATION_STARTED + }) + + await em.flush() + return { status: 200, body: { @@ -153,6 +175,11 @@ export default class PlayerAuthAPIService extends APIService { } } else { const sessionToken = await alias.player.auth.createSession(alias) + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.LOGGED_IN + }) + await em.flush() return { @@ -190,6 +217,11 @@ export default class PlayerAuthAPIService extends APIService { const redisCode = await redis.get(this.getRedisAuthKey(key, alias)) if (!redisCode || code !== redisCode) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.VERIFICATION_FAILED + }) + await em.flush() + req.ctx.throw(403, { message: 'Invalid code', errorCode: PlayerAuthErrorCode.VERIFICATION_CODE_INVALID @@ -199,6 +231,11 @@ export default class PlayerAuthAPIService extends APIService { await redis.del(this.getRedisAuthKey(key, alias)) const sessionToken = await alias.player.auth.createSession(alias) + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.LOGGED_IN + }) + await em.flush() return { @@ -222,6 +259,11 @@ export default class PlayerAuthAPIService extends APIService { alias.player.auth.sessionKey = null alias.player.auth.sessionCreatedAt = null + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.LOGGED_OUT + }) + await em.flush() return { @@ -244,6 +286,14 @@ export default class PlayerAuthAPIService extends APIService { const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password) if (!passwordMatches) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + } + }) + await em.flush() + req.ctx.throw(403, { message: 'Current password is incorrect', errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS @@ -252,6 +302,14 @@ export default class PlayerAuthAPIService extends APIService { const isSamePassword = await bcrypt.compare(newPassword, alias.player.auth.password) if (isSamePassword) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.NEW_PASSWORD_MATCHES_CURRENT_PASSWORD + } + }) + await em.flush() + req.ctx.throw(400, { message: 'Please choose a different password', errorCode: PlayerAuthErrorCode.NEW_PASSWORD_MATCHES_CURRENT_PASSWORD @@ -259,6 +317,11 @@ export default class PlayerAuthAPIService extends APIService { } alias.player.auth.password = await bcrypt.hash(newPassword, 10) + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGED_PASSWORD + }) + await em.flush() return { @@ -281,6 +344,14 @@ export default class PlayerAuthAPIService extends APIService { const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password) if (!passwordMatches) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + } + }) + await em.flush() + req.ctx.throw(403, { message: 'Current password is incorrect', errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS @@ -289,13 +360,30 @@ export default class PlayerAuthAPIService extends APIService { const isSameEmail = newEmail === alias.player.auth.email if (isSameEmail) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.NEW_EMAIL_MATCHES_CURRENT_EMAIL + } + }) + await em.flush() + req.ctx.throw(400, { message: 'Please choose a different email address', errorCode: PlayerAuthErrorCode.NEW_EMAIL_MATCHES_CURRENT_EMAIL }) } + const oldEmail = alias.player.auth.email alias.player.auth.email = newEmail + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGED_EMAIL, + extra: { + oldEmail + } + }) + await em.flush() return { @@ -330,6 +418,12 @@ export default class PlayerAuthAPIService extends APIService { const code = generateSixDigitCode() await redis.set(this.getRedisPasswordResetKey(key, code), alias.id, 'EX', 900) await queueEmail(req.ctx.emailQueue, new PlayerAuthResetPassword(alias, code)) + + createPlayerAuthActivity(req, playerAuth.player, { + type: PlayerAuthActivityType.PASSWORD_RESET_REQUESTED + }) + + await em.flush() } return { @@ -370,6 +464,75 @@ export default class PlayerAuthAPIService extends APIService { alias.player.auth.password = await bcrypt.hash(password, 10) alias.player.auth.sessionKey = null alias.player.auth.sessionCreatedAt = null + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.PASSWORD_RESET_COMPLETED + }) + + await em.flush() + + return { + status: 204 + } + } + + @Validate({ + body: ['currentPassword', 'verificationEnabled'] + }) + @HasPermission(PlayerAuthAPIPolicy, 'toggleVerification') + async toggleVerification(req: Request): Promise { + const { currentPassword, verificationEnabled, email } = req.body + const em: EntityManager = req.ctx.em + + const alias = await em.getRepository(PlayerAlias).findOne(req.ctx.state.currentAliasId, { + populate: ['player.auth'] + }) + + if (verificationEnabled && !alias.player.auth.email && !email) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.VERIFICATION_EMAIL_REQUIRED, + verificationEnabled: Boolean(verificationEnabled) + } + }) + await em.flush() + + req.ctx.throw(400, { + message: 'An email address is required to enable verification', + errorCode: PlayerAuthErrorCode.VERIFICATION_EMAIL_REQUIRED + }) + } + + const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password) + if (!passwordMatches) { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS, + verificationEnabled: Boolean(verificationEnabled) + } + }) + await em.flush() + + req.ctx.throw(403, { + message: 'Current password is incorrect', + errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS + }) + } + + alias.player.auth.verificationEnabled = Boolean(verificationEnabled) + if (email?.trim()) { + alias.player.auth.email = email + } + + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.VERFICIATION_TOGGLED, + extra: { + verificationEnabled: alias.player.auth.verificationEnabled + } + }) + await em.flush() return { diff --git a/src/services/data-export.service.ts b/src/services/data-export.service.ts index db6334c2..382f2dab 100644 --- a/src/services/data-export.service.ts +++ b/src/services/data-export.service.ts @@ -393,7 +393,7 @@ export default class DataExportService extends Service { orgPlanAction.extra.dataExportId = dataExport.id } - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.DATA_EXPORT_REQUESTED, diff --git a/src/services/game-feedback.service.ts b/src/services/game-feedback.service.ts index f51d2210..2eb7e3bc 100644 --- a/src/services/game-feedback.service.ts +++ b/src/services/game-feedback.service.ts @@ -119,7 +119,7 @@ export default class GameFeedbackService extends Service { feedbackCategory.description = description feedbackCategory.anonymised = anonymised - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: feedbackCategory.game, type: GameActivityType.GAME_FEEDBACK_CATEGORY_CREATED, @@ -158,7 +158,7 @@ export default class GameFeedbackService extends Service { } } - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: feedbackCategory.game, type: GameActivityType.GAME_FEEDBACK_CATEGORY_UPDATED, @@ -184,7 +184,7 @@ export default class GameFeedbackService extends Service { async deleteCategory(req: Request): Promise { const em: EntityManager = req.ctx.em - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.feedbackCategory.game, type: GameActivityType.GAME_FEEDBACK_CATEGORY_DELETED, diff --git a/src/services/game-stat.service.ts b/src/services/game-stat.service.ts index 5255be68..562c64cf 100644 --- a/src/services/game-stat.service.ts +++ b/src/services/game-stat.service.ts @@ -45,7 +45,7 @@ export default class GameStatService extends Service { stat.maxValue = maxValue stat.minTimeBetweenUpdates = minTimeBetweenUpdates - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: stat.game, type: GameActivityType.GAME_STAT_CREATED, @@ -84,7 +84,7 @@ export default class GameStatService extends Service { } } - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: stat.game, type: GameActivityType.GAME_STAT_UPDATED, @@ -110,7 +110,7 @@ export default class GameStatService extends Service { async delete(req: Request): Promise { const em: EntityManager = req.ctx.em - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.stat.game, type: GameActivityType.GAME_STAT_DELETED, diff --git a/src/services/game.service.ts b/src/services/game.service.ts index 895628ef..c9c75776 100644 --- a/src/services/game.service.ts +++ b/src/services/game.service.ts @@ -50,7 +50,7 @@ export default class GameService extends Service { game.props = sanitiseProps(mergedProps, true) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game, type: GameActivityType.GAME_PROPS_UPDATED, diff --git a/src/services/integration.service.ts b/src/services/integration.service.ts index a693f127..7568d0ef 100644 --- a/src/services/integration.service.ts +++ b/src/services/integration.service.ts @@ -93,7 +93,7 @@ export default class IntegrationService extends Service { const integration = new Integration(type, req.ctx.state.game, pick(config, configKeys[type])) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.GAME_INTEGRATION_ADDED, @@ -122,7 +122,7 @@ export default class IntegrationService extends Service { const newConfig = pick(config, configKeys[integration.type]) integration.updateConfig(newConfig) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.GAME_INTEGRATION_UPDATED, @@ -151,7 +151,7 @@ export default class IntegrationService extends Service { const integration: Integration = req.ctx.state.integration integration.deletedAt = new Date() - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.GAME_INTEGRATION_DELETED, @@ -173,7 +173,7 @@ export default class IntegrationService extends Service { await this.queue.add('sync-leaderboards', { integrationId: Number(req.params.id), type: 'leaderboards' }) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.GAME_INTEGRATION_STEAMWORKS_LEADERBOARDS_SYNCED @@ -192,7 +192,7 @@ export default class IntegrationService extends Service { await this.queue.add('sync-stats', { integrationId: Number(req.params.id), type: 'stats' }) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.GAME_INTEGRATION_STEAMWORKS_STATS_SYNCED diff --git a/src/services/invite.service.ts b/src/services/invite.service.ts index 7ad48e69..9dc634bb 100644 --- a/src/services/invite.service.ts +++ b/src/services/invite.service.ts @@ -56,7 +56,7 @@ export default class InviteService extends Service { invite.type = type invite.invitedByUser = inviter - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, type: GameActivityType.INVITE_CREATED, extra: { diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index 61ee419d..26287197 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -65,7 +65,7 @@ export default class LeaderboardService extends Service { leaderboard.sortMode = sortMode leaderboard.unique = unique - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: leaderboard.game, type: GameActivityType.LEADERBOARD_CREATED, @@ -160,7 +160,7 @@ export default class LeaderboardService extends Service { if (toggleVisibility) { entry.hidden = hidden - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: entry.leaderboard.game, type: hidden ? GameActivityType.LEADERBOARD_ENTRY_HIDDEN : GameActivityType.LEADERBOARD_ENTRY_RESTORED, @@ -207,7 +207,7 @@ export default class LeaderboardService extends Service { } } - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: leaderboard.game, type: GameActivityType.LEADERBOARD_UPDATED, @@ -238,7 +238,7 @@ export default class LeaderboardService extends Service { const em: EntityManager = req.ctx.em const leaderboardInternalName = req.ctx.state.leaderboard.internalName - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.leaderboard.game, type: GameActivityType.LEADERBOARD_DELETED, diff --git a/src/services/player-group.service.ts b/src/services/player-group.service.ts index 7e1f34e5..d3d270e1 100644 --- a/src/services/player-group.service.ts +++ b/src/services/player-group.service.ts @@ -69,7 +69,7 @@ export default class PlayerGroupService extends Service { group.rules = this.buildRulesFromData(rules) await group.checkMembership(em) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.PLAYER_GROUP_CREATED, @@ -101,7 +101,7 @@ export default class PlayerGroupService extends Service { group.rules = this.buildRulesFromData(rules) await group.checkMembership(em) - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.PLAYER_GROUP_UPDATED, @@ -178,7 +178,7 @@ export default class PlayerGroupService extends Service { async delete(req: Request): Promise { const em: EntityManager = req.ctx.em - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: req.ctx.state.game, type: GameActivityType.PLAYER_GROUP_DELETED, diff --git a/src/services/player.service.ts b/src/services/player.service.ts index 577509bb..36ff36e2 100644 --- a/src/services/player.service.ts +++ b/src/services/player.service.ts @@ -16,6 +16,7 @@ import PlayerProp from '../entities/player-prop' import PlayerGroup from '../entities/player-group' import GameSave from '../entities/game-save' import { PlayerAuthErrorCode } from '../entities/player-auth' +import PlayerAuthActivity from '../entities/player-auth-activity' const propsValidation = async (val: unknown): Promise => [ { @@ -59,6 +60,11 @@ type PlayerPostBody = { method: 'GET', path: '/:id/saves', handler: 'saves' + }, + { + method: 'GET', + path: '/:id/auth-activities', + handler: 'authActivities' } ]) export default class PlayerService extends Service { @@ -219,7 +225,7 @@ export default class PlayerService extends Service { } if (req.ctx.state.user.api !== true) { - await createGameActivity(em, { + createGameActivity(em, { user: req.ctx.state.user, game: player.game, type: GameActivityType.PLAYER_PROPS_UPDATED, @@ -316,4 +322,21 @@ export default class PlayerService extends Service { } } } + + @HasPermission(PlayerPolicy, 'getAuthActivities') + async authActivities(req: Request): Promise { + const em: EntityManager = req.ctx.em + const activities = await em.getRepository(PlayerAuthActivity).find({ + player: req.ctx.state.player + }, { + populate: ['player.aliases'] + }) + + return { + status: 200, + body: { + activities + } + } + } } diff --git a/src/services/public/user-public.service.ts b/src/services/public/user-public.service.ts index b37cd655..bed01c45 100644 --- a/src/services/public/user-public.service.ts +++ b/src/services/public/user-public.service.ts @@ -119,7 +119,7 @@ export default class UserPublicService extends Service { user.type = invite.type user.emailConfirmed = true - await createGameActivity(em, { user, type: GameActivityType.INVITE_ACCEPTED }) + createGameActivity(em, { user, type: GameActivityType.INVITE_ACCEPTED }) await em.remove(invite) } else { diff --git a/tests/fixtures/PlayerAuthActivityFactory.ts b/tests/fixtures/PlayerAuthActivityFactory.ts new file mode 100644 index 00000000..262ca21b --- /dev/null +++ b/tests/fixtures/PlayerAuthActivityFactory.ts @@ -0,0 +1,28 @@ +import { Factory } from 'hefty' +import casual from 'casual' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../src/entities/player-auth-activity' +import PlayerFactory from './PlayerFactory' +import Game from '../../src/entities/game' + +export default class PlayerAuthActivityFactory extends Factory { + game: Game + + constructor(game: Game) { + super(PlayerAuthActivity, 'base') + this.register('base', this.base) + + this.game = game + } + + protected async base(): Promise> { + return { + type: casual.random_element([ + PlayerAuthActivityType.REGISTERED, + PlayerAuthActivityType.VERIFICATION_STARTED, + PlayerAuthActivityType.LOGGED_IN, + PlayerAuthActivityType.LOGGED_OUT + ]), + player: await new PlayerFactory([this.game]).state('with talo alias').one() + } + } +} diff --git a/tests/services/_api/player-api/patch.test.ts b/tests/services/_api/player-api/patch.test.ts index a6ec3342..c22fe635 100644 --- a/tests/services/_api/player-api/patch.test.ts +++ b/tests/services/_api/player-api/patch.test.ts @@ -155,10 +155,14 @@ describe('Player API service - patch', () => { const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_PLAYERS]) const player = await new PlayerFactory([apiKey.game]).one() + player.setProps([ + { + key: casual.word, + value: casual.word + } + ]) await (global.em).persistAndFlush(player) - const propsLength = player.props.length - const res = await request(global.app) .patch(`/v1/players/${player.id}`) .send({ @@ -176,6 +180,6 @@ describe('Player API service - patch', () => { .auth(token, { type: 'bearer' }) .expect(200) - expect(res.body.player.props).toHaveLength(propsLength + 1) + expect(res.body.player.props).toHaveLength(2) }) }) diff --git a/tests/services/_api/player-auth-api/changeEmail.test.ts b/tests/services/_api/player-auth-api/changeEmail.test.ts index 66bb560d..bfd3b86b 100644 --- a/tests/services/_api/player-auth-api/changeEmail.test.ts +++ b/tests/services/_api/player-auth-api/changeEmail.test.ts @@ -5,6 +5,7 @@ import PlayerFactory from '../../../fixtures/PlayerFactory' import { EntityManager } from '@mikro-orm/mysql' import bcrypt from 'bcrypt' import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - change email', () => { it('should change a player\'s email if the current password is correct and the api key has the correct scopes', async () => { @@ -34,6 +35,15 @@ describe('Player auth API service - change email', () => { await (global.em).refresh(player.auth) expect(player.auth.email).toBe('bozza@mail.com') + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGED_EMAIL, + player: player.id, + extra: { + oldEmail: 'boz@mail.com' + } + }) + expect(activity).not.toBeNull() }) it('should not change a player\'s email if the api key does not have the correct scopes', async () => { @@ -91,6 +101,15 @@ describe('Player auth API service - change email', () => { message: 'Current password is incorrect', errorCode: 'INVALID_CREDENTIALS' }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + player: player.id, + extra: { + errorCode: 'INVALID_CREDENTIALS' + } + }) + expect(activity).not.toBeNull() }) it('should not change a player\'s email if the current email is the same as the new email', async () => { @@ -122,5 +141,14 @@ describe('Player auth API service - change email', () => { message: 'Please choose a different email address', errorCode: 'NEW_EMAIL_MATCHES_CURRENT_EMAIL' }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + player: player.id, + extra: { + errorCode: 'NEW_EMAIL_MATCHES_CURRENT_EMAIL' + } + }) + expect(activity).not.toBeNull() }) }) diff --git a/tests/services/_api/player-auth-api/changePassword.test.ts b/tests/services/_api/player-auth-api/changePassword.test.ts index 2c5bb562..b86d097f 100644 --- a/tests/services/_api/player-auth-api/changePassword.test.ts +++ b/tests/services/_api/player-auth-api/changePassword.test.ts @@ -5,6 +5,7 @@ import PlayerFactory from '../../../fixtures/PlayerFactory' import { EntityManager } from '@mikro-orm/mysql' import bcrypt from 'bcrypt' import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - change password', () => { it('should change a player\'s password if the current password is correct and the api key has the correct scopes', async () => { @@ -34,6 +35,12 @@ describe('Player auth API service - change password', () => { await (global.em).refresh(player.auth) expect(await bcrypt.compare('password1', player.auth.password)).toBe(true) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGED_PASSWORD, + player: player.id + }) + expect(activity).not.toBeNull() }) it('should not change a player\'s password if the api key does not have the correct scopes', async () => { @@ -91,6 +98,15 @@ describe('Player auth API service - change password', () => { message: 'Current password is incorrect', errorCode: 'INVALID_CREDENTIALS' }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, + player: player.id, + extra: { + errorCode: 'INVALID_CREDENTIALS' + } + }) + expect(activity).not.toBeNull() }) it('should not change a player\'s password if the current password is the same as the new password', async () => { @@ -122,5 +138,14 @@ describe('Player auth API service - change password', () => { message: 'Please choose a different password', errorCode: 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD' }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGE_PASSWORD_FAILED, + player: player.id, + extra: { + errorCode: 'NEW_PASSWORD_MATCHES_CURRENT_PASSWORD' + } + }) + expect(activity).not.toBeNull() }) }) diff --git a/tests/services/_api/player-auth-api/forgotPassword.test.ts b/tests/services/_api/player-auth-api/forgotPassword.test.ts index 571007c9..3b94d303 100644 --- a/tests/services/_api/player-auth-api/forgotPassword.test.ts +++ b/tests/services/_api/player-auth-api/forgotPassword.test.ts @@ -7,6 +7,7 @@ import Redis from 'ioredis' import redisConfig from '../../../../src/config/redis.config' import SendGrid from '@sendgrid/mail' import casual from 'casual' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - forgot password', () => { const sendMock = vi.spyOn(SendGrid, 'send') @@ -35,6 +36,12 @@ describe('Player auth API service - forgot password', () => { expect(await redis.keys(`player-auth:${apiKey.game.id}:password-reset:*`)).toHaveLength(1) expect(sendMock).toHaveBeenCalledOnce() + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.PASSWORD_RESET_REQUESTED, + player: player.id + }) + expect(activity).not.toBeNull() + await redis.quit() }) diff --git a/tests/services/_api/player-auth-api/login.test.ts b/tests/services/_api/player-auth-api/login.test.ts index 3797fcc0..b17a34f1 100644 --- a/tests/services/_api/player-auth-api/login.test.ts +++ b/tests/services/_api/player-auth-api/login.test.ts @@ -8,6 +8,7 @@ import bcrypt from 'bcrypt' import SendGrid from '@sendgrid/mail' import Redis from 'ioredis' import redisConfig from '../../../../src/config/redis.config' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - login', () => { const sendMock = vi.spyOn(SendGrid, 'send') @@ -44,6 +45,12 @@ describe('Player auth API service - login', () => { }) expect(res.body.sessionToken).toBeTruthy() + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.LOGGED_IN, + player: player.id + }) + expect(activity).not.toBeNull() }) it('should not login a player if the api key does not have the correct scopes', async () => { @@ -146,6 +153,12 @@ describe('Player auth API service - login', () => { expect(await redis.get(`player-auth:${apiKey.game.id}:verification:${alias.id}`)).toHaveLength(6) expect(sendMock).toHaveBeenCalledOnce() + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERIFICATION_STARTED, + player: player.id + }) + expect(activity).not.toBeNull() + await redis.quit() }) }) diff --git a/tests/services/_api/player-auth-api/logout.test.ts b/tests/services/_api/player-auth-api/logout.test.ts index 8ad7efb9..1f614ce7 100644 --- a/tests/services/_api/player-auth-api/logout.test.ts +++ b/tests/services/_api/player-auth-api/logout.test.ts @@ -3,6 +3,7 @@ import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' import PlayerFactory from '../../../fixtures/PlayerFactory' import { EntityManager } from '@mikro-orm/mysql' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - logout', () => { it('should logout a player if the api key has the correct scopes', async () => { @@ -26,6 +27,12 @@ describe('Player auth API service - logout', () => { await (global.em).refresh(player.auth) expect(player.auth.sessionKey).toBeNull() expect(player.auth.sessionCreatedAt).toBeNull() + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.LOGGED_OUT, + player: player.id + }) + expect(activity).not.toBeNull() }) it('should not logout a player if the api key does not have the correct scopes', async () => { diff --git a/tests/services/_api/player-auth-api/register.test.ts b/tests/services/_api/player-auth-api/register.test.ts index 93a0e301..181a8cb1 100644 --- a/tests/services/_api/player-auth-api/register.test.ts +++ b/tests/services/_api/player-auth-api/register.test.ts @@ -2,6 +2,8 @@ import request from 'supertest' import { APIKeyScope } from '../../../../src/entities/api-key' import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' import casual from 'casual' +import { EntityManager } from '@mikro-orm/mysql' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - register', () => { it('should register a player if the api key has the correct scopes', async () => { @@ -26,6 +28,14 @@ describe('Player auth API service - register', () => { }) expect(res.body.sessionToken).toBeTruthy() + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.REGISTERED, + extra: { + verificationEnabled: false + } + }) + expect(activity).not.toBeNull() }) it('should not register a player if the api key does not have the correct scopes', async () => { @@ -84,6 +94,14 @@ describe('Player auth API service - register', () => { }) expect(res.body.sessionToken).toBeTruthy() + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.REGISTERED, + extra: { + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() }) it('should register not register a player if verification is enabled but no email is provided', async () => { diff --git a/tests/services/_api/player-auth-api/resetPassword.test.ts b/tests/services/_api/player-auth-api/resetPassword.test.ts index e6e284f5..14ffd6a6 100644 --- a/tests/services/_api/player-auth-api/resetPassword.test.ts +++ b/tests/services/_api/player-auth-api/resetPassword.test.ts @@ -6,6 +6,7 @@ import { EntityManager } from '@mikro-orm/mysql' import Redis from 'ioredis' import redisConfig from '../../../../src/config/redis.config' import bcrypt from 'bcrypt' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - reset password', () => { it('should reset a player\'s password if the reset code is correct and if the api key has the correct scopes', async () => { @@ -32,6 +33,12 @@ describe('Player auth API service - reset password', () => { expect(player.auth.sessionKey).toBeNull() expect(player.auth.sessionCreatedAt).toBeNull() + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.PASSWORD_RESET_COMPLETED, + player: player.id + }) + expect(activity).not.toBeNull() + await redis.quit() }) diff --git a/tests/services/_api/player-auth-api/toggleVerification.test.ts b/tests/services/_api/player-auth-api/toggleVerification.test.ts new file mode 100644 index 00000000..99de10ce --- /dev/null +++ b/tests/services/_api/player-auth-api/toggleVerification.test.ts @@ -0,0 +1,233 @@ +import request from 'supertest' +import { APIKeyScope } from '../../../../src/entities/api-key' +import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken' +import PlayerFactory from '../../../fixtures/PlayerFactory' +import { EntityManager } from '@mikro-orm/mysql' +import bcrypt from 'bcrypt' +import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' + +describe('Player auth API service - toggle verification', () => { + it('should enable verification if the current password is correct and an email is provided', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: false + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'password', verificationEnabled: true }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(204) + + await (global.em).refresh(player.auth) + expect(player.auth.verificationEnabled).toBe(true) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERFICIATION_TOGGLED, + player: player.id, + extra: { + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() + }) + + it('should disable verification if the current password is correct', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: true + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'password', verificationEnabled: false }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(204) + + await (global.em).refresh(player.auth) + expect(player.auth.verificationEnabled).toBe(false) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERFICIATION_TOGGLED, + player: player.id, + extra: { + verificationEnabled: false + } + }) + expect(activity).not.toBeNull() + }) + + it('should not enable verification if an email is not provided', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: null, + verificationEnabled: false + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + const res = await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'password', verificationEnabled: true }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(400) + + expect(res.body).toStrictEqual({ + message: 'An email address is required to enable verification', + errorCode: 'VERIFICATION_EMAIL_REQUIRED' + }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + player: player.id, + extra: { + errorCode: 'VERIFICATION_EMAIL_REQUIRED', + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() + }) + + it('should update the email of the player if one is sent', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: null, + verificationEnabled: false + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'password', verificationEnabled: true, email: 'bozzz@mail.com' }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(204) + + await (global.em).refresh(player.auth) + expect(player.auth.verificationEnabled).toBe(true) + expect(player.auth.email).toBe('bozzz@mail.com') + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERFICIATION_TOGGLED, + player: player.id, + extra: { + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() + }) + + it('should not toggle verification if the current password is incorrect', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: false + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + const res = await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'wrongpassword', verificationEnabled: true }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(403) + + expect(res.body).toStrictEqual({ + message: 'Current password is incorrect', + errorCode: 'INVALID_CREDENTIALS' + }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + player: player.id, + extra: { + errorCode: 'INVALID_CREDENTIALS', + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() + }) + + it('should not toggle verification if the api key does not have the correct scopes', async () => { + const [apiKey, token] = await createAPIKeyAndToken([]) + + const player = await new PlayerFactory([apiKey.game]).state('with talo alias').with(async () => ({ + auth: await new PlayerAuthFactory().with(async () => ({ + password: await bcrypt.hash('password', 10), + email: 'boz@mail.com', + verificationEnabled: false + })).one() + })).one() + const alias = player.aliases[0] + await (global.em).persistAndFlush(player) + + const sessionToken = await player.auth.createSession(alias) + await (global.em).flush() + + await request(global.app) + .patch('/v1/players/auth/toggle_verification') + .send({ currentPassword: 'password', verificationEnabled: true }) + .auth(token, { type: 'bearer' }) + .set('x-talo-player', player.id) + .set('x-talo-alias', String(alias.id)) + .set('x-talo-session', sessionToken) + .expect(403) + }) +}) diff --git a/tests/services/_api/player-auth-api/verify.test.ts b/tests/services/_api/player-auth-api/verify.test.ts index d3bfef59..4cba29f2 100644 --- a/tests/services/_api/player-auth-api/verify.test.ts +++ b/tests/services/_api/player-auth-api/verify.test.ts @@ -7,6 +7,7 @@ import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' import bcrypt from 'bcrypt' import Redis from 'ioredis' import redisConfig from '../../../../src/config/redis.config' +import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' describe('Player auth API service - verify', () => { it('should login a player if the verification code is correct and if the api key has the correct scopes', async () => { @@ -100,6 +101,12 @@ describe('Player auth API service - verify', () => { expect(await redis.get(`player-auth:${apiKey.game.id}:verification:${alias.id}`)).toBe('123456') + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.VERIFICATION_FAILED, + player: player.id + }) + expect(activity).not.toBeNull() + await redis.quit() }) }) diff --git a/tests/services/player/authActivities.test.ts b/tests/services/player/authActivities.test.ts new file mode 100644 index 00000000..2cfaa5fb --- /dev/null +++ b/tests/services/player/authActivities.test.ts @@ -0,0 +1,57 @@ +import { EntityManager } from '@mikro-orm/mysql' +import request from 'supertest' +import PlayerFactory from '../../fixtures/PlayerFactory' +import createUserAndToken from '../../utils/createUserAndToken' +import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import userPermissionProvider from '../../utils/userPermissionProvider' +import { UserType } from '../../../src/entities/user' +import PlayerAuthActivityFactory from '../../fixtures/PlayerAuthActivityFactory' + +describe('Player service - get auth activities', () => { + it.each(userPermissionProvider([UserType.ADMIN]))('should return a %i for a %s user', async (statusCode, _, type) => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type }, organisation) + + const player = await new PlayerFactory([game]).state('with talo alias').one() + const activities = await new PlayerAuthActivityFactory(game).with(() => ({ player })).many(10) + + await (global.em).persistAndFlush(activities) + + const res = await request(global.app) + .get(`/games/${game.id}/players/${player.id}/auth-activities`) + .auth(token, { type: 'bearer' }) + .expect(statusCode) + + if (statusCode === 200) { + expect(res.body.activities).toHaveLength(10) + } else { + expect(res.body).toStrictEqual({ message: 'You do not have permissions to view player auth activities' }) + } + }) + + it('should not get a player\'s auth activities for a player they have no access to', async () => { + const [, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }) + + const player = await new PlayerFactory([game]).one() + + await (global.em).persistAndFlush(player) + + await request(global.app) + .get(`/games/${game.id}/players/${player.id}/auth-activities`) + .auth(token, { type: 'bearer' }) + .expect(403) + }) + + it('should not get a player\'s auth activities if they do not exist', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({ type: UserType.ADMIN }, organisation) + + const res = await request(global.app) + .get(`/games/${game.id}/players/21312321321/auth-activities`) + .auth(token, { type: 'bearer' }) + .expect(404) + + expect(res.body).toStrictEqual({ message: 'Player not found' }) + }) +}) diff --git a/tests/services/player/saves.test.ts b/tests/services/player/saves.test.ts index d95319c6..f4a5c5dd 100644 --- a/tests/services/player/saves.test.ts +++ b/tests/services/player/saves.test.ts @@ -6,7 +6,6 @@ import createOrganisationAndGame from '../../utils/createOrganisationAndGame' import GameSaveFactory from '../../fixtures/GameSaveFactory' describe('Player service - get saves', () => { - it('should get a player\'s saves', async () => { const [organisation, game] = await createOrganisationAndGame() const [token] = await createUserAndToken({}, organisation)