Skip to content

Commit 06f37ff

Browse files
authored
Merge pull request #47 from TaloDev/develop
2FA
2 parents 1a7c3fb + 8bb1b72 commit 06f37ff

31 files changed

+1141
-27
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ dist/
77
coverage/
88
backup.sql
99
.eslintcache
10+
tests/cache.json

__mocks__/ioredis.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import fs from 'fs/promises'
2+
3+
interface CacheItem {
4+
key: string
5+
value: string | number
6+
extra: (string | number)[]
7+
}
8+
9+
export class RedisMock {
10+
filepath = 'tests/cache.json'
11+
12+
async _init(): Promise<void> {
13+
await this._write([])
14+
}
15+
16+
async _read(): Promise<CacheItem[]> {
17+
const content = await fs.readFile(this.filepath, 'utf-8')
18+
return JSON.parse(content)
19+
}
20+
21+
async _write(data: CacheItem[]): Promise<void> {
22+
await fs.writeFile(this.filepath, JSON.stringify(data))
23+
}
24+
}
25+
26+
export default class Redis extends RedisMock {
27+
async get(key: string): Promise<string | number> {
28+
const data = await this._read()
29+
return data.find((item) => item.key === key)?.value
30+
}
31+
32+
async set(key: string, value: string | number, ...extra: (string | number)[]): Promise<void> {
33+
const data = await this.del(key)
34+
data.push({
35+
key,
36+
value,
37+
extra
38+
})
39+
40+
await this._write(data)
41+
}
42+
43+
async del(key: string): Promise<CacheItem[]> {
44+
let data = await this._read()
45+
data = data.filter((item) => item.key !== key)
46+
await this._write(data)
47+
48+
return data
49+
}
50+
}

envs/.env.dev

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ SENTRY_DSN=
1616

1717
AUTO_CONFIRM_EMAIL=false
1818
FROM_EMAIL=hello@trytalo.com
19+
20+
RECOVERY_CODES_SECRET=tc0d8e0h0lqv5isajfjw0iivj5pc3d95

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// eslint-disable-next-line no-undef
12
module.exports = {
23
preset: 'ts-jest',
34
testEnvironment: 'node',

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "game-services",
3-
"version": "0.1.2",
3+
"version": "0.2.0",
44
"description": "",
55
"main": "src/index.ts",
66
"scripts": {
@@ -14,6 +14,7 @@
1414
"logs": "yarn dc logs --follow backend",
1515
"restart": "yarn down && yarn up",
1616
"migration:create": "DB_HOST=127.0.0.1 mikro-orm migration:create",
17+
"migration:up": "DB_HOST=127.0.0.1 mikro-orm migration:up",
1718
"service:create": "hygen service new",
1819
"prepare": "husky install",
1920
"lint": "eslint src/**/*.ts tests/**/*.ts"
@@ -71,6 +72,8 @@
7172
"lodash.get": "^4.4.2",
7273
"lodash.groupby": "^4.6.0",
7374
"lodash.uniqwith": "^4.5.0",
75+
"otplib": "^12.0.1",
76+
"qrcode": "^1.5.0",
7477
"uuid": "^8.3.2"
7578
},
7679
"mikro-orm": {

src/entities/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import Prop from './prop'
1212
import User from './user'
1313
import UserAccessCode from './user-access-code'
1414
import UserSession from './user-session'
15+
import UserTwoFactorAuth from './user-two-factor-auth'
16+
import UserRecoveryCode from './user-recovery-code'
1517

1618
export default [
19+
UserRecoveryCode,
20+
UserTwoFactorAuth,
1721
Leaderboard,
1822
LeaderboardEntry,
1923
DataExport,

src/entities/user-recovery-code.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
2+
import User from './user'
3+
import crypto from 'crypto'
4+
5+
const IV_LENGTH = 16
6+
7+
@Entity()
8+
export default class UserRecoveryCode {
9+
@PrimaryKey()
10+
id: number
11+
12+
@ManyToOne(() => User)
13+
user: User
14+
15+
@Property()
16+
code: string = this.generateCode()
17+
18+
@Property()
19+
createdAt: Date = new Date()
20+
21+
constructor(user: User) {
22+
this.user = user
23+
}
24+
25+
generateCode(): string {
26+
const characters = 'ABCDEFGHIJKMNOPQRSTUVWXYZ0123456789'
27+
let code = ''
28+
29+
for (let i = 0; i < 10; i++ ) {
30+
code += characters.charAt(Math.floor(Math.random() * characters.length))
31+
}
32+
33+
const iv = Buffer.from(crypto.randomBytes(IV_LENGTH)).toString('hex').slice(0, IV_LENGTH)
34+
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(process.env.RECOVERY_CODES_SECRET), iv)
35+
let encrypted = cipher.update(code)
36+
37+
encrypted = Buffer.concat([encrypted, cipher.final()])
38+
return iv + ':' + encrypted.toString('hex')
39+
}
40+
41+
getPlainCode(): string {
42+
const textParts: string[] = this.code.split(':')
43+
44+
const iv = Buffer.from(textParts.shift(), 'binary')
45+
const encryptedText = Buffer.from(textParts.join(':'), 'hex')
46+
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(process.env.RECOVERY_CODES_SECRET), iv)
47+
let decrypted = decipher.update(encryptedText)
48+
49+
decrypted = Buffer.concat([decrypted, decipher.final()])
50+
return decrypted.toString()
51+
}
52+
53+
toJSON() {
54+
return this.getPlainCode()
55+
}
56+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Entity, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'
2+
import User from './user'
3+
4+
@Entity()
5+
export default class UserTwoFactorAuth {
6+
@PrimaryKey()
7+
id: number
8+
9+
@OneToOne(() => User, (user) => user.twoFactorAuth)
10+
user: User
11+
12+
@Property({ hidden: true })
13+
secret: string
14+
15+
@Property()
16+
enabled: boolean
17+
18+
constructor(secret: string) {
19+
this.secret = secret
20+
}
21+
22+
toJSON() {
23+
return {
24+
id: this.id,
25+
enabled: this.enabled
26+
}
27+
}
28+
}

src/entities/user.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { Entity, Enum, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
1+
import { Collection, Entity, Enum, ManyToOne, OneToMany, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'
22
import Organisation from './organisation'
3+
import UserRecoveryCode from './user-recovery-code'
4+
import UserTwoFactorAuth from './user-two-factor-auth'
35

46
export enum UserType {
57
DEV,
@@ -30,6 +32,12 @@ export default class User {
3032
@Property({ default: false })
3133
emailConfirmed: boolean
3234

35+
@OneToOne({ nullable: true, orphanRemoval: true, eager: true })
36+
twoFactorAuth: UserTwoFactorAuth
37+
38+
@OneToMany(() => UserRecoveryCode, (recoveryCode) => recoveryCode.user, { orphanRemoval: true })
39+
recoveryCodes: Collection<UserRecoveryCode> = new Collection<UserRecoveryCode>(this)
40+
3341
@Property()
3442
createdAt: Date = new Date()
3543

@@ -43,7 +51,8 @@ export default class User {
4351
lastSeenAt: this.lastSeenAt,
4452
emailConfirmed: this.emailConfirmed,
4553
organisation: this.organisation,
46-
type: this.type
54+
type: this.type,
55+
has2fa: this.twoFactorAuth?.enabled ?? false
4756
}
4857
}
4958
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import User from '../../entities/user'
2+
import UserRecoveryCode from '../../entities/user-recovery-code'
3+
4+
export default function generateRecoveryCodes(user: User): UserRecoveryCode[] {
5+
return [...new Array(8)].map(() => new UserRecoveryCode(user))
6+
}

0 commit comments

Comments
 (0)