Skip to content

Commit 9325fcc

Browse files
authored
Merge pull request #336 from TaloDev/delete-auth
Allow players to delete their Talo auth accounts
2 parents e4ded12 + 825024d commit 9325fcc

File tree

9 files changed

+244
-2
lines changed

9 files changed

+244
-2
lines changed

src/docs/player-auth-api.docs.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,27 @@ const PlayerAuthAPIDocs: APIDocs<PlayerAuthAPIService> = {
276276
}
277277
}
278278
]
279+
},
280+
delete: {
281+
description: 'Delete a player account',
282+
params: {
283+
headers: {
284+
'x-talo-player': 'The ID of the player',
285+
'x-talo-alias': 'The ID of the player\'s alias',
286+
'x-talo-session': 'The session token'
287+
},
288+
body: {
289+
currentPassword: 'The current password of the player'
290+
}
291+
},
292+
samples: [
293+
{
294+
title: 'Sample request',
295+
sample: {
296+
currentPassword: 'password'
297+
}
298+
}
299+
]
279300
}
280301
}
281302

src/entities/player-alias.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cascade, Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql'
1+
import { Cascade, Entity, Filter, ManyToOne, PrimaryKey, Property } from '@mikro-orm/mysql'
22
import Player from './player'
33

44
export enum PlayerAliasService {
@@ -11,6 +11,7 @@ export enum PlayerAliasService {
1111
}
1212

1313
@Entity()
14+
@Filter({ name: 'notAnonymised', cond: { anonymised: false }, default: true })
1415
export default class PlayerAlias {
1516
@PrimaryKey()
1617
id: number
@@ -24,6 +25,9 @@ export default class PlayerAlias {
2425
@ManyToOne(() => Player, { cascade: [Cascade.REMOVE], eager: true })
2526
player: Player
2627

28+
@Property({ default: false })
29+
anonymised: boolean
30+
2731
@Property()
2832
createdAt: Date = new Date()
2933

src/entities/player-auth-activity.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ export enum PlayerAuthActivityType {
1515
VERFICIATION_TOGGLED,
1616
CHANGE_PASSWORD_FAILED,
1717
CHANGE_EMAIL_FAILED,
18-
TOGGLE_VERIFICATION_FAILED
18+
TOGGLE_VERIFICATION_FAILED,
19+
DELETED_AUTH,
20+
DELETE_AUTH_FAILED
1921
}
2022

2123
@Entity()
@@ -76,6 +78,10 @@ export default class PlayerAuthActivity {
7678
return `${authAlias.identifier} failed to change their email`
7779
case PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED:
7880
return `${authAlias.identifier} failed to toggle verification`
81+
case PlayerAuthActivityType.DELETED_AUTH:
82+
return `${authAlias.identifier} deleted their account`
83+
case PlayerAuthActivityType.DELETE_AUTH_FAILED:
84+
return `${authAlias.identifier} failed to delete their account`
7985
default:
8086
return ''
8187
}

src/migrations/.snapshot-gs_dev.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,6 +1230,17 @@
12301230
"length": 255,
12311231
"mappedType": "string"
12321232
},
1233+
"anonymised": {
1234+
"name": "anonymised",
1235+
"type": "tinyint(1)",
1236+
"unsigned": false,
1237+
"autoincrement": false,
1238+
"primary": false,
1239+
"nullable": false,
1240+
"length": 1,
1241+
"default": "false",
1242+
"mappedType": "boolean"
1243+
},
12331244
"created_at": {
12341245
"name": "created_at",
12351246
"type": "datetime",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Migration } from '@mikro-orm/migrations'
2+
3+
export class AddPlayerAliasAnonymisedColumn extends Migration {
4+
5+
override async up(): Promise<void> {
6+
this.addSql('alter table `player_alias` add `anonymised` tinyint(1) not null default false;')
7+
}
8+
9+
override async down(): Promise<void> {
10+
this.addSql('alter table `player_alias` drop column `anonymised`;')
11+
}
12+
13+
}

src/migrations/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { AddAPIKeyUpdatedAtColumn } from './20240614122547AddAPIKeyUpdatedAtColu
2727
import { CreatePlayerAuthTable } from './20240628155142CreatePlayerAuthTable'
2828
import { CreatePlayerAuthActivityTable } from './20240725183402CreatePlayerAuthActivityTable'
2929
import { UpdatePlayerAliasServiceColumn } from './20240916213402UpdatePlayerAliasServiceColumn'
30+
import { AddPlayerAliasAnonymisedColumn } from './20240920121232AddPlayerAliasAnonymisedColumn'
3031

3132
export default [
3233
{
@@ -144,5 +145,9 @@ export default [
144145
{
145146
name: 'UpdatePlayerAliasServiceColumn',
146147
class: UpdatePlayerAliasServiceColumn
148+
},
149+
{
150+
name: 'AddPlayerAliasAnonymisedColumn',
151+
class: AddPlayerAliasAnonymisedColumn
147152
}
148153
]

src/policies/api/player-auth-api.policy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ export default class PlayerAuthAPIPolicy extends Policy {
3838
async toggleVerification(): Promise<PolicyResponse> {
3939
return await this.hasScopes([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
4040
}
41+
42+
async delete(): Promise<PolicyResponse> {
43+
return await this.hasScopes([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
44+
}
4145
}

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ import { PlayerAuthActivityType } from '../../entities/player-auth-activity'
7070
path: '/toggle_verification',
7171
handler: 'toggleVerification',
7272
docs: PlayerAuthAPIDocs.toggleVerification
73+
},
74+
{
75+
method: 'DELETE',
76+
path: '/',
77+
handler: 'delete',
78+
docs: PlayerAuthAPIDocs.delete
7379
}
7480
])
7581
export default class PlayerAuthAPIService extends APIService {
@@ -540,4 +546,53 @@ export default class PlayerAuthAPIService extends APIService {
540546
status: 204
541547
}
542548
}
549+
550+
@Validate({
551+
headers: ['x-talo-player', 'x-talo-alias', 'x-talo-session'],
552+
body: ['currentPassword']
553+
})
554+
@HasPermission(PlayerAuthAPIPolicy, 'delete')
555+
async delete(req: Request): Promise<Response> {
556+
const { currentPassword } = req.body
557+
const em: EntityManager = req.ctx.em
558+
559+
const alias = await em.getRepository(PlayerAlias).findOne(req.ctx.state.currentAliasId, {
560+
populate: ['player.auth']
561+
})
562+
563+
const passwordMatches = await bcrypt.compare(currentPassword, alias.player.auth.password)
564+
if (!passwordMatches) {
565+
createPlayerAuthActivity(req, alias.player, {
566+
type: PlayerAuthActivityType.DELETE_AUTH_FAILED,
567+
extra: {
568+
errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS
569+
}
570+
})
571+
await em.flush()
572+
573+
req.ctx.throw(403, {
574+
message: 'Current password is incorrect',
575+
errorCode: PlayerAuthErrorCode.INVALID_CREDENTIALS
576+
})
577+
}
578+
579+
em.remove(alias.player.auth)
580+
581+
const prevIdentifier = alias.identifier
582+
alias.identifier = `anonymised+${Date.now()}`
583+
alias.anonymised = true
584+
585+
createPlayerAuthActivity(req, alias.player, {
586+
type: PlayerAuthActivityType.DELETED_AUTH,
587+
extra: {
588+
identifier: prevIdentifier
589+
}
590+
})
591+
592+
await em.flush()
593+
594+
return {
595+
status: 204
596+
}
597+
}
543598
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import request from 'supertest'
2+
import { APIKeyScope } from '../../../../src/entities/api-key'
3+
import createAPIKeyAndToken from '../../../utils/createAPIKeyAndToken'
4+
import PlayerFactory from '../../../fixtures/PlayerFactory'
5+
import { EntityManager } from '@mikro-orm/mysql'
6+
import bcrypt from 'bcrypt'
7+
import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory'
8+
import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity'
9+
import PlayerAlias from '../../../../src/entities/player-alias'
10+
11+
describe('Player auth API service - delete', () => {
12+
it('should delete the account if the current password is correct', async () => {
13+
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
14+
15+
const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({
16+
auth: await new PlayerAuthFactory().state(async () => ({
17+
password: await bcrypt.hash('password', 10)
18+
})).one()
19+
})).one()
20+
const alias = player.aliases[0]
21+
await (<EntityManager>global.em).persistAndFlush(player)
22+
23+
const sessionToken = await player.auth.createSession(alias)
24+
await (<EntityManager>global.em).flush()
25+
26+
const prevIdentifier = alias.identifier
27+
28+
await request(global.app)
29+
.delete('/v1/players/auth/')
30+
.send({ currentPassword: 'password' })
31+
.auth(token, { type: 'bearer' })
32+
.set('x-talo-player', player.id)
33+
.set('x-talo-alias', String(alias.id))
34+
.set('x-talo-session', sessionToken)
35+
.expect(204)
36+
37+
38+
expect(await (<EntityManager>global.em).refresh(player.auth)).toBeNull()
39+
40+
await (<EntityManager>global.em).refresh(player, { populate: ['aliases'] })
41+
expect(player.aliases).toHaveLength(0) // anonymous filter
42+
43+
const anonymisedAlias = await (<EntityManager>global.em).getRepository(PlayerAlias).findOne(alias.id, {
44+
filters: {
45+
notAnonymised: false
46+
},
47+
refresh: true
48+
})
49+
expect(anonymisedAlias.identifier.startsWith('anonymised+')).toBe(true)
50+
expect(anonymisedAlias.anonymised).toBe(true)
51+
52+
const activity = await (<EntityManager>global.em).getRepository(PlayerAuthActivity).findOne({
53+
type: PlayerAuthActivityType.DELETED_AUTH,
54+
player: player.id,
55+
extra: {
56+
identifier: prevIdentifier
57+
}
58+
})
59+
expect(activity).not.toBeNull()
60+
})
61+
62+
it('should not delete the account if the current password is incorrect', async () => {
63+
const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS])
64+
65+
const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({
66+
auth: await new PlayerAuthFactory().state(async () => ({
67+
password: await bcrypt.hash('password', 10)
68+
})).one()
69+
})).one()
70+
const alias = player.aliases[0]
71+
await (<EntityManager>global.em).persistAndFlush(player)
72+
73+
const sessionToken = await player.auth.createSession(alias)
74+
await (<EntityManager>global.em).flush()
75+
76+
const res = await request(global.app)
77+
.delete('/v1/players/auth/')
78+
.send({ currentPassword: 'wrongpassword' })
79+
.auth(token, { type: 'bearer' })
80+
.set('x-talo-player', player.id)
81+
.set('x-talo-alias', String(alias.id))
82+
.set('x-talo-session', sessionToken)
83+
.expect(403)
84+
85+
expect(res.body).toStrictEqual({
86+
message: 'Current password is incorrect',
87+
errorCode: 'INVALID_CREDENTIALS'
88+
})
89+
90+
await (<EntityManager>global.em).refresh(player.auth)
91+
expect(player.auth).not.toBeUndefined()
92+
93+
const activity = await (<EntityManager>global.em).getRepository(PlayerAuthActivity).findOne({
94+
type: PlayerAuthActivityType.DELETE_AUTH_FAILED,
95+
player: player.id
96+
})
97+
expect(activity).not.toBeNull()
98+
})
99+
100+
it('should not delete the account if the api key does not have the correct scopes', async () => {
101+
const [apiKey, token] = await createAPIKeyAndToken([])
102+
103+
const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({
104+
auth: await new PlayerAuthFactory().state(async () => ({
105+
password: await bcrypt.hash('password', 10)
106+
})).one()
107+
})).one()
108+
const alias = player.aliases[0]
109+
await (<EntityManager>global.em).persistAndFlush(player)
110+
111+
const sessionToken = await player.auth.createSession(alias)
112+
await (<EntityManager>global.em).flush()
113+
114+
await request(global.app)
115+
.delete('/v1/players/auth/')
116+
.send({ currentPassword: 'password' })
117+
.auth(token, { type: 'bearer' })
118+
.set('x-talo-player', player.id)
119+
.set('x-talo-alias', String(alias.id))
120+
.set('x-talo-session', sessionToken)
121+
.expect(403)
122+
})
123+
})

0 commit comments

Comments
 (0)