Skip to content

Commit

Permalink
feat(client): client tokens (#298)
Browse files Browse the repository at this point in the history
* client tokens

* fix

* fix security flaw

* fix tests

* use client tokens

* fixes

* fix mig test

* fix

* fix tests

* migration start

* down migrate

* remove useless sync stuff

* fix

* fix

* legacy token verification

* fix

* fix test
  • Loading branch information
samuelmasse committed Jan 13, 2022
1 parent 8d01733 commit 709f120
Show file tree
Hide file tree
Showing 33 changed files with 547 additions and 183 deletions.
5 changes: 3 additions & 2 deletions packages/client/test/e2e/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {

const FAKE_UUID = uuid()
const FAKE_CLIENT_ID = uuid()
const FAKE_CLIENT_TOKEN = 'djhejsfj3498frh9erf8j3948fj398fj3498fj349f8j349834jf934fj93284fj3498fj3498fj3498f3j4f983'
const FAKE_CLIENT_TOKEN =
'djhejsfj3498frh9erf8j3948fj398fj3498fj349f8j3dfgfdgfdsrswe49834jf934fj93284fj3498fj3498fj3498f3j4f983ffsdfddasddasdasdasdasda'

describe('Http Client', () => {
test('Should create a client with credential information and URL', async () => {
Expand Down Expand Up @@ -98,7 +99,7 @@ describe('Http Client', () => {
await expect(client.syncs.sync(config)).rejects.toThrow('Request failed with status code 403')
})

test('Should not throw an error when the both credentials are invalid', async () => {
test('Should not throw an error when both credentials are invalid', async () => {
const config: SyncRequest = { id: FAKE_CLIENT_ID, token: FAKE_CLIENT_TOKEN }

await expect(client.syncs.sync(config)).resolves.not.toEqual({
Expand Down
5 changes: 4 additions & 1 deletion packages/engine/src/migration/migration.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Knex } from 'knex'
import { Logger } from '..'

export abstract class Migration {
protected trx!: Knex.Transaction
protected logger!: Logger
protected isDown!: boolean
protected isLite!: boolean

abstract get meta(): MigrationMeta

async init(trx: Knex.Transaction, isDown: boolean, isLite: boolean) {
async init(trx: Knex.Transaction, logger: Logger, isDown: boolean, isLite: boolean) {
this.trx = trx
this.logger = logger
this.isDown = isDown
this.isLite = isLite
}
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/migration/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class MigrationService extends Service {

for (const migration of migrations) {
this.loggerDry.info(`Running ${migration.meta.name}`)
await migration.init(trx, this.isDown, this.db.getIsLite())
await migration.init(trx, this.loggerDry, this.isDown, this.db.getIsLite())

if (await migration.shouldRun()) {
await migration.run()
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ export class Api {

constructor(private app: App, private root: Router) {
this.router = Router()
this.auth = new Auth(app.clients)
this.auth = new Auth(app.clientTokens)
this.manager = new ApiManager(this.router, this.auth)

this.syncs = new SyncApi(this.app.syncs, this.app.clients, this.app.channels)
this.syncs = new SyncApi(this.app.syncs, this.app.clients, this.app.clientTokens, this.app.channels)
this.health = new HealthApi(this.app.health)
this.users = new UserApi(this.app.users)
this.conversations = new ConversationApi(this.app.users, this.app.conversations)
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Engine } from '@botpress/messaging-engine'
import { ChannelService } from './channels/service'
import { ClientTokenService } from './client-tokens/service'
import { ClientService } from './clients/service'
import { ConduitService } from './conduits/service'
import { ConversationService } from './conversations/service'
Expand All @@ -21,6 +22,7 @@ export class App extends Engine {
channels: ChannelService
providers: ProviderService
clients: ClientService
clientTokens: ClientTokenService
webhooks: WebhookService
conduits: ConduitService
users: UserService
Expand All @@ -39,7 +41,8 @@ export class App extends Engine {
super()
this.channels = new ChannelService(this.database)
this.providers = new ProviderService(this.database, this.caching)
this.clients = new ClientService(this.database, this.crypto, this.caching, this.providers)
this.clients = new ClientService(this.database, this.caching, this.providers)
this.clientTokens = new ClientTokenService(this.database, this.crypto, this.caching)
this.webhooks = new WebhookService(this.database, this.caching, this.crypto)
this.conduits = new ConduitService(this.database, this.crypto, this.caching, this.channels, this.providers)
this.users = new UserService(this.database, this.caching, this.batching)
Expand Down Expand Up @@ -77,6 +80,7 @@ export class App extends Engine {
this.providers,
this.conduits,
this.clients,
this.clientTokens,
this.webhooks,
this.status
)
Expand All @@ -99,6 +103,7 @@ export class App extends Engine {
await this.channels.setup()
await this.providers.setup()
await this.clients.setup()
await this.clientTokens.setup()
await this.webhooks.setup()
await this.conduits.setup()
await this.users.setup()
Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/base/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { ClientService } from '../../clients/service'
import { ClientTokenService } from '../../client-tokens/service'
import { ClientAuthHandler } from './client'
import { PublicAuthHandler } from './public'

export class Auth {
public readonly client: ClientAuthHandler
public readonly public: PublicAuthHandler

constructor(clients: ClientService) {
this.client = new ClientAuthHandler(clients)
constructor(clientTokens: ClientTokenService) {
this.client = new ClientAuthHandler(clientTokens)
this.public = new PublicAuthHandler()
}
}
19 changes: 7 additions & 12 deletions packages/server/src/base/auth/client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Request, Response, NextFunction } from 'express'
import { validate as validateUuid } from 'uuid'
import { ClientService } from '../../clients/service'
import { Client } from '../../clients/types'
import { uuid } from '@botpress/messaging-base'
import { Request, Response } from 'express'
import { ClientTokenService } from '../../client-tokens/service'
import { AuthHandler, Middleware } from './base'

export class ClientAuthHandler extends AuthHandler {
constructor(private clients: ClientService) {
constructor(private clientTokens: ClientTokenService) {
super()
}

Expand All @@ -14,22 +13,18 @@ export class ClientAuthHandler extends AuthHandler {
const clientId = req.headers['x-bp-messaging-client-id'] as string
const clientToken = req.headers['x-bp-messaging-client-token'] as string

if (!validateUuid(clientId)) {
return res.sendStatus(401)
}

const client = await this.clients.getByIdAndToken(clientId, clientToken)
const client = await this.clientTokens.verifyToken(clientId, clientToken)
if (!client) {
return res.sendStatus(401)
}

const clientApiReq = req as ClientApiRequest
clientApiReq.client = client
clientApiReq.clientId = clientId
return fn(clientApiReq, res)
})
}
}

export type ClientApiRequest = Request & {
client: Client
clientId: uuid
}
159 changes: 159 additions & 0 deletions packages/server/src/client-tokens/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { uuid } from '@botpress/messaging-base'
import { CachingService, CryptoService, DatabaseService, ServerCache, Service } from '@botpress/messaging-engine'
import crypto from 'crypto'
import { validate as validateUuid, v4 as uuidv4 } from 'uuid'
import { ClientTokenTable } from './table'
import { ClientToken } from './types'

export const CLIENT_TOKEN_LENGTH = 66

export class ClientTokenService extends Service {
private table: ClientTokenTable
private cacheById!: ServerCache<uuid, ClientToken>
private cacheTokens!: ServerCache<uuid, string>
private cacheTokensByClient!: ServerCache<uuid, ClientToken[]>

constructor(
private db: DatabaseService,
private cryptoService: CryptoService,
private cachingService: CachingService
) {
super()
this.table = new ClientTokenTable()
}

async setup() {
this.cacheById = await this.cachingService.newServerCache('cache_client_token_by_id')
this.cacheTokens = await this.cachingService.newServerCache('cache_client_token_raw')
this.cacheTokensByClient = await this.cachingService.newServerCache('cache_tokens_by_client')

await this.db.registerTable(this.table)
}

async generateToken(): Promise<string> {
return crypto.randomBytes(CLIENT_TOKEN_LENGTH).toString('base64')
}

async create(clientId: uuid, token: string, expiry: Date | undefined): Promise<ClientToken> {
const clientToken = {
id: uuidv4(),
clientId,
token: await this.cryptoService.hash(token),
expiry
}

await this.query().insert(this.serialize(clientToken))
this.cacheById.set(clientToken.id, clientToken)
this.cacheTokensByClient.del(clientId, true)

return clientToken
}

async fetchById(id: uuid): Promise<ClientToken | undefined> {
const cached = this.cacheById.get(id)
if (cached) {
return cached
}

const rows = await this.query().where({ id })

if (rows?.length) {
const clientToken = this.deserialize(rows[0])
this.cacheById.set(id, clientToken)
return clientToken
} else {
return undefined
}
}

async listByClient(clientId: uuid): Promise<ClientToken[]> {
const cached = this.cacheTokensByClient.get(clientId)
if (cached) {
return cached
}

const rows = await this.query().where({ clientId })
const tokens = rows.map((x) => this.deserialize(x))
this.cacheTokensByClient.set(clientId, tokens)

return tokens
}

async verifyToken(clientId: uuid, rawToken: string): Promise<ClientToken | undefined> {
if (!rawToken?.length) {
return undefined
}

const parts = rawToken.split('.')
if (parts.length === 2) {
const [id, token] = parts
return this.verifyClientToken(clientId, id, token)
} else if (parts.length === 1) {
return this.verifyLegacyToken(clientId, rawToken)
} else {
return undefined
}
}

private async verifyClientToken(clientId: uuid, id: uuid, token: string) {
if (!validateUuid(id) || !token?.length) {
return undefined
}

const clientToken = await this.fetchById(id)
if (!clientToken) {
return undefined
}

if (clientToken.clientId !== clientId) {
return undefined
}

if (clientToken.expiry && Date.now() > clientToken.expiry.getTime()) {
return undefined
}

const cachedToken = this.cacheTokens.get(id)
if (cachedToken) {
if (token === cachedToken) {
return clientToken
} else {
return undefined
}
}

if (await this.cryptoService.compareHash(clientToken.token, token)) {
this.cacheTokens.set(id, token)
return clientToken
} else {
return undefined
}
}

private async verifyLegacyToken(clientId: uuid, token: string): Promise<ClientToken | undefined> {
const clientTokens = await this.listByClient(clientId)
if (clientTokens.length !== 1) {
return undefined
}

return this.verifyClientToken(clientId, clientTokens[0].id, token)
}

private query() {
return this.db.knex(this.table.id)
}

private serialize(clientToken: Partial<ClientToken>) {
return {
...clientToken,
expiry: this.db.setDate(clientToken.expiry)
}
}

private deserialize(clientToken: any): ClientToken {
return {
...clientToken,
expiry: clientToken.expiry ? this.db.getDate(clientToken.expiry) : undefined
}
}
}
15 changes: 15 additions & 0 deletions packages/server/src/client-tokens/table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Table } from '@botpress/messaging-engine'
import { Knex } from 'knex'

export class ClientTokenTable extends Table {
get id() {
return 'msg_client_tokens'
}

create(table: Knex.CreateTableBuilder) {
table.uuid('id').primary()
table.uuid('clientId').references('id').inTable('msg_clients')
table.string('token').notNullable()
table.timestamp('expiry').nullable()
}
}
8 changes: 8 additions & 0 deletions packages/server/src/client-tokens/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { uuid } from '@botpress/messaging-base'

export interface ClientToken {
id: uuid
clientId: uuid
token: string
expiry: Date | undefined
}

0 comments on commit 709f120

Please sign in to comment.