diff --git a/README.md b/README.md index 514a756e..eac435e7 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,41 @@ -# Talo backend +# Talo backend: self-hostable game dev tools -Talo's backend is a set of self-hostable services that helps you build games faster and make better decisions. +Talo is a collection of tools and APIs designed to make game development easier and to help you make better data-driven decisions. -## Features -- ⚡️ [Event tracking](https://trytalo.com/events) -- 👥 [Player management](https://trytalo.com/players) (including cross-session data, groups and identity management) -- 🎮 [Unity package](https://trytalo.com/unity) -- 🎮 [Godot plugin](https://trytalo.com/godot) -- 🗃️ Data exports -- 🕹️ [Leaderboards](https://trytalo.com/leaderboards) -- 💾 [Game saves](https://trytalo.com/saves) -- 📊 [Game stats](https://trytalo.com/stats) (global and per-player) -- ⚙️ [Live config](https://trytalo.com/live-config) (update your game config from the web, no releases required) -- 🔧 [Steamworks integration](https://trytalo.com/steamworks-integration) -- 💬 [Game feedback](https://trytalo.com/feedback) +From essentials like player management, stats and leaderboards to advanced APIs for game saves, event tracking and player authentication. -## Docs +Talo is available to use via our [Godot plugin](https://github.com/TaloDev/godot), [Unity package](https://github.com/TaloDev/unity) or [REST API](https://docs.trytalo.com/docs/http/authentication). -Our docs are [available here](https://docs.trytalo.com). +## Talo's key features -## Self-hosting +- 👥 [Player management](https://trytalo.com/players): Persist player data across sessions, create segments and handle authentication. +- ⚡️ [Event tracking](https://trytalo.com/events): Track in-game player actions individually and globally. +- 🎮 [Godot plugin](https://trytalo.com/godot): Easily integrate Talo into your Godot game. +- 🎮 [Unity package](https://trytalo.com/unity): Easily integrate Talo into your Unity game. +- 🗃️ **Data Exports**: Create CSVs of your Talo data like players, events and feedback. +- 🕹️ [Leaderboards](https://trytalo.com/leaderboards): Highly customisable leaderboards that can sync with Steamworks. +- 💾 [Game saves](https://trytalo.com/saves): A simple and flexible way to load/save game state; also works offline. +- 📊 [Game stats](https://trytalo.com/stats): Track global or per-player stats across your game; also syncs with Steamworks. +- ⚙️ [Live config](https://trytalo.com/live-config): Update game settings from the web with zero downtime. +- 🔧 [Steamworks integration](https://trytalo.com/steamworks-integration): Hook into Steamworks for authentication and ownership checks. +- 💬 [Game feedback](https://trytalo.com/feedback): Collect and manage feedback from your players. -See the [self-hosting docs](https://docs.trytalo.com/docs/selfhosting/overview) and the [self-hosting example repo](https://github.com/TaloDev/hosting). +## Documentation -## Contributing +Check out the [full Talo docs](https://docs.trytalo.com) for setup instructions, detailed API docs/examples and configuration options. -Looking to contribute? Head over to our [contribution guide](CONTRIBUTING.md) to learn how to install the project, run the tests and generate new services. +## Self-hosting your own Talo instance -## Discord +Talo is designed to be easily self-hosted. Take a look at our [self-hosting guide](https://docs.trytalo.com/docs/selfhosting/overview) and the [GitHub repo](https://github.com/TaloDev/hosting) for examples on how to get started. -For help and support, [join our Discord](https://discord.gg/2RWwxXVY3v). +## Contributing to Talo + +Thinking about contributing to Talo? We’d love the help! Head over to our [contribution guide](CONTRIBUTING.md) to learn how to set up the project, run tests, and start adding new features. + +## Join our community + +Have questions, want to share feedback or show off your game? [Join us on Discord](https://trytalo.com/discord) to connect with other developers and get help from the Talo team. + +--- + +Find all the details about Talo on our [website](https://trytalo.com)! diff --git a/package-lock.json b/package-lock.json index 6fef59e3..a78c7a92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.48.0", + "version": "0.49.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.48.0", + "version": "0.49.0", "license": "MIT", "dependencies": { "@clickhouse/client": "^1.4.1", diff --git a/package.json b/package.json index 06002125..489e852e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.48.0", + "version": "0.49.0", "description": "", "main": "src/index.ts", "scripts": { diff --git a/src/emails/mail.ts b/src/emails/mail.ts index c6adca1b..57a760e3 100644 --- a/src/emails/mail.ts +++ b/src/emails/mail.ts @@ -38,7 +38,7 @@ export default class Mail { this.preheader = preheader this.footer = 'Need help?' - this.footerText = 'Our team and community can be found on Discord.' + this.footerText = 'Our team and community can be found on Discord.' this.why = 'You are receiving this email because you have a Talo account' } diff --git a/src/entities/player-auth.ts b/src/entities/player-auth.ts index 6b4c3994..8ac83dbe 100644 --- a/src/entities/player-auth.ts +++ b/src/entities/player-auth.ts @@ -15,7 +15,8 @@ export enum PlayerAuthErrorCode { 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', - VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED' + VERIFICATION_EMAIL_REQUIRED = 'VERIFICATION_EMAIL_REQUIRED', + INVALID_EMAIL = 'INVALID_EMAIL' } @Entity() diff --git a/src/lib/lang/emailRegex.ts b/src/lib/lang/emailRegex.ts new file mode 100644 index 00000000..c0fc10fb --- /dev/null +++ b/src/lib/lang/emailRegex.ts @@ -0,0 +1 @@ +export default new RegExp(/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/) diff --git a/src/services/api/player-auth-api.service.ts b/src/services/api/player-auth-api.service.ts index 7afb3755..79527efc 100644 --- a/src/services/api/player-auth-api.service.ts +++ b/src/services/api/player-auth-api.service.ts @@ -15,6 +15,7 @@ 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' +import emailRegex from '../../lib/lang/emailRegex' @Routes([ { @@ -105,7 +106,21 @@ 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 + + if (email?.trim()) { + const sanitisedEmail = email.trim().toLowerCase() + if (emailRegex.test(sanitisedEmail)) { + alias.player.auth.email = sanitisedEmail + } else { + req.ctx.throw(400, { + message: 'Invalid email address', + errorCode: PlayerAuthErrorCode.INVALID_EMAIL + }) + } + } else { + alias.player.auth.email = null + } + alias.player.auth.verificationEnabled = Boolean(verificationEnabled) em.persist(alias.player.auth) @@ -381,7 +396,23 @@ export default class PlayerAuthAPIService extends APIService { } const oldEmail = alias.player.auth.email - alias.player.auth.email = newEmail + const sanitisedEmail = (newEmail as string).trim().toLowerCase() + if (emailRegex.test(sanitisedEmail)) { + alias.player.auth.email = sanitisedEmail + } else { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.INVALID_EMAIL + } + }) + await em.flush() + + req.ctx.throw(400, { + message: 'Invalid email address', + errorCode: PlayerAuthErrorCode.INVALID_EMAIL + }) + } createPlayerAuthActivity(req, alias.player, { type: PlayerAuthActivityType.CHANGED_EMAIL, @@ -530,7 +561,24 @@ export default class PlayerAuthAPIService extends APIService { alias.player.auth.verificationEnabled = Boolean(verificationEnabled) if (email?.trim()) { - alias.player.auth.email = email + const sanitisedEmail = (email as string).trim().toLowerCase() + if (emailRegex.test(sanitisedEmail)) { + alias.player.auth.email = sanitisedEmail + } else { + createPlayerAuthActivity(req, alias.player, { + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + extra: { + errorCode: PlayerAuthErrorCode.INVALID_EMAIL, + verificationEnabled: Boolean(verificationEnabled) + } + }) + await em.flush() + + req.ctx.throw(400, { + message: 'Invalid email address', + errorCode: PlayerAuthErrorCode.INVALID_EMAIL + }) + } } createPlayerAuthActivity(req, alias.player, { diff --git a/src/services/public/user-public.service.ts b/src/services/public/user-public.service.ts index d277444a..3aa7299d 100644 --- a/src/services/public/user-public.service.ts +++ b/src/services/public/user-public.service.ts @@ -1,4 +1,4 @@ -import { After, Service, Request, Response, Routes, Validate } from 'koa-clay' +import { After, Service, Request, Response, Routes, Validate, ValidationCondition } from 'koa-clay' import User, { UserType } from '../../entities/user' import jwt from 'jsonwebtoken' import { promisify } from 'util' @@ -23,6 +23,7 @@ import handlePricingPlanAction from '../../lib/billing/handlePricingPlanAction' import { PricingPlanActionType } from '../../entities/pricing-plan-action' import queueEmail from '../../lib/messaging/queueEmail' import ResetPassword from '../../emails/reset-password' +import emailRegex from '../../lib/lang/emailRegex' async function sendEmailConfirm(req: Request, res: Response): Promise { const user: User = req.ctx.state.user @@ -80,7 +81,13 @@ export default class UserPublicService extends Service { @Validate({ body: { email: { - required: true + required: true, + validation: async (val: string): Promise => [ + { + check: emailRegex.test(val), + error: 'Email address is invalid' + } + ] }, username: { required: true @@ -103,10 +110,10 @@ export default class UserPublicService extends Service { const userWithEmail = await em.getRepository(User).findOne({ email }) const orgWithEmail = await em.getRepository(Organisation).findOne({ email }) - if (userWithEmail || orgWithEmail) req.ctx.throw(400, 'That email address is already in use') + if (userWithEmail || orgWithEmail) req.ctx.throw(400, 'Email address is already in use') const user = new User() - user.email = email.toLowerCase() + user.email = email.trim().toLowerCase() user.username = username user.password = await bcrypt.hash(password, 10) user.emailConfirmed = process.env.AUTO_CONFIRM_EMAIL === 'true' diff --git a/tests/services/_api/player-auth-api/changeEmail.test.ts b/tests/services/_api/player-auth-api/changeEmail.test.ts index 718e74c2..bfd8ad35 100644 --- a/tests/services/_api/player-auth-api/changeEmail.test.ts +++ b/tests/services/_api/player-auth-api/changeEmail.test.ts @@ -6,6 +6,7 @@ import { EntityManager } from '@mikro-orm/mysql' import bcrypt from 'bcrypt' import PlayerAuthFactory from '../../../fixtures/PlayerAuthFactory' import PlayerAuthActivity, { PlayerAuthActivityType } from '../../../../src/entities/player-auth-activity' +import casual from 'casual' 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 () => { @@ -151,4 +152,44 @@ describe('Player auth API service - change email', () => { }) expect(activity).not.toBeNull() }) + + it('should not change a player\'s email if the new email is invalid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({ + auth: await new PlayerAuthFactory().state(async () => ({ + password: await bcrypt.hash('password', 10), + email: casual.email, + verificationEnabled: casual.boolean + })).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) + .post('/v1/players/auth/change_email') + .send({ currentPassword: 'password', newEmail: 'blah' }) + .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: 'Invalid email address', + errorCode: 'INVALID_EMAIL' + }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.CHANGE_EMAIL_FAILED, + player: player.id, + extra: { + errorCode: 'INVALID_EMAIL' + } + }) + expect(activity).not.toBeNull() + }) }) diff --git a/tests/services/_api/player-auth-api/register.test.ts b/tests/services/_api/player-auth-api/register.test.ts index 181a8cb1..69f4b270 100644 --- a/tests/services/_api/player-auth-api/register.test.ts +++ b/tests/services/_api/player-auth-api/register.test.ts @@ -104,7 +104,7 @@ describe('Player auth API service - register', () => { expect(activity).not.toBeNull() }) - it('should register not register a player if verification is enabled but no email is provided', async () => { + it('should not register a player if verification is enabled but no email is provided', async () => { const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) const res = await request(global.app) @@ -119,4 +119,19 @@ describe('Player auth API service - register', () => { } }) }) + + it('should not register a player if verification is enabled but the email is invalid', async () => { + const [, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const res = await request(global.app) + .post('/v1/players/auth/register') + .send({ identifier: casual.username, email: 'blah', password: 'password', verificationEnabled: true }) + .auth(token, { type: 'bearer' }) + .expect(400) + + expect(res.body).toStrictEqual({ + message: 'Invalid email address', + errorCode: 'INVALID_EMAIL' + }) + }) }) diff --git a/tests/services/_api/player-auth-api/toggleVerification.test.ts b/tests/services/_api/player-auth-api/toggleVerification.test.ts index 8a487a3a..e09cd338 100644 --- a/tests/services/_api/player-auth-api/toggleVerification.test.ts +++ b/tests/services/_api/player-auth-api/toggleVerification.test.ts @@ -230,4 +230,45 @@ describe('Player auth API service - toggle verification', () => { .set('x-talo-session', sessionToken) .expect(403) }) + + it('should not enable verification if the provided email is invalid', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.READ_PLAYERS, APIKeyScope.WRITE_PLAYERS]) + + const player = await new PlayerFactory([apiKey.game]).withTaloAlias().state(async () => ({ + auth: await new PlayerAuthFactory().state(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, email: 'blah' }) + .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: 'Invalid email address', + errorCode: 'INVALID_EMAIL' + }) + + const activity = await (global.em).getRepository(PlayerAuthActivity).findOne({ + type: PlayerAuthActivityType.TOGGLE_VERIFICATION_FAILED, + player: player.id, + extra: { + errorCode: 'INVALID_EMAIL', + verificationEnabled: true + } + }) + expect(activity).not.toBeNull() + }) }) diff --git a/tests/services/_public/user-public/register.test.ts b/tests/services/_public/user-public/register.test.ts index 471875b4..9e19e3b1 100644 --- a/tests/services/_public/user-public/register.test.ts +++ b/tests/services/_public/user-public/register.test.ts @@ -40,7 +40,7 @@ describe('User public service - register', () => { .send({ email, username: casual.username, password: 'password', organisationName: 'Talo' }) .expect(400) - expect(res.body).toStrictEqual({ message: 'That email address is already in use' }) + expect(res.body).toStrictEqual({ message: 'Email address is already in use' }) }) it('should create an access code for a new user', async () => { @@ -113,4 +113,17 @@ describe('User public service - register', () => { .send({ email, username, password: 'password', inviteToken: 'abc123' }) .expect(404) }) + + it('should not let a user register if their email is invalid', async () => { + const res = await request(global.app) + .post('/public/users/register') + .send({ email: 'bleh', username: casual.username, password: 'password', organisationName: 'Talo' }) + .expect(400) + + expect(res.body).toStrictEqual({ + errors: { + email: ['Email address is invalid'] + } + }) + }) })