Skip to content

Commit

Permalink
feat(credentials): added external auth support for secure communication
Browse files Browse the repository at this point in the history
  • Loading branch information
allardy committed Feb 12, 2019
1 parent 72d6c38 commit ccaa3b6
Show file tree
Hide file tree
Showing 16 changed files with 338 additions and 26 deletions.
72 changes: 71 additions & 1 deletion docs/guide/docs/developers/tutorials.md
Expand Up @@ -48,6 +48,21 @@ Here is some sample code for adding the event listeners to your custom elements:
</script>
```

#### Obtaining the User ID of your visitor

It may be useful to fetch the current visitor ID to either save it in your database or to update some attributes in the Botpress DB.

Since the webchat is running in an iframe, communication between frames is done by posting messages.
The chat will dispatch an event when the user id is set, which you can listen for on your own page.

```js
window.addEventListener('message', message => {
if (message.data.userId) {
console.log(`The User ID is ` + message.data.userId)
}
})
```

### Customizing the look and feel of the Webchat

The Webchat view is customizable by passing additional params to the `init` function, below are the options available:
Expand All @@ -67,10 +82,65 @@ window.botpressWebChat.init({
showConversationsButton: true, // Whether or not to show the conversations button
showUserName: false, // Whether or not to show the user's name
showUserAvatar: false, // Whether or not to show the user's avatar
enableTranscriptDownload: false // Whether or not to show the transcript download button
enableTranscriptDownload: false, // Whether or not to show the transcript download button
externalAuthToken: 'my jwt token', // Defines a token that is sent with each messages to Botpress
userId: null // Allows you to override the default user id. Make sure it is not possible to guess it!
})
```

#### Configuring the Webchat during a conversation

The method `window.botpressWebChat.configure` allows you to change the configuration of the chat during a conversation without having to reload the page

## Connecting your bot with your existing backend

Botpress makes it easy to communicate data securely between your backend and your bot using JWT. Store any data you'd like in the token and pass it to the webchat using the `externalAuthToken` configuration option.

If the token is valid, the content will be available under `event.credentials` when processing the event. If there is no token or if it is invalid, `credentials` will stay undefined.

How to enable:

1. Configure `pro.externalAuth` in the file `botpress.config.json`
2. Create a file named `key.pub` in the folder `data/global` containing the public key

How to use:

You can either define the token when the chat is initialized: `window.botpressWebChat.init({ externalAuthToken: 'userToken' })` or you can configure it while the conversation is ongoing: `window.botpressWebChat.configure({ externalAuthToken: 'userToken' })`.

### Persisting the user's profile

Once the user is authenticated, you may want to extract some informations out of the credentials to save them in the `user` state, like the First name, Last name, etc. All you need to do is set up a hook listening for a certain type of event, for example `update_profile`. Then, just select the required fields

Example:

```js
if (event.type === 'update_profile') {
if (event.credentials) {
event.state.user = {
firstname: event.credentials.firstname,
lastname: event.credentials.lastname
}

// This tells the State Manager to persist the values you defined for `user`
event.setFlag(bp.IO.WellKnownFlags.FORCE_PERSIST_STATE, true)
} else {
console.log('Token seems invalid ')
}

// Since it's a custom event, we can safely skip the dialog engine
event.setFlag(bp.IO.WellKnownFlags.SKIP_DIALOG_ENGINE, true)
}
```

Then send a custom event : `window.botpressWebChat.sendEvent({ type: 'update_profile' })`

### Using a Custom User ID

Users get a new unique User ID each time they use a different device, since it is stored in the local storage. To offer a consistent user experience across different devices, you may want to provide your customer with the same Botpress User ID to make available past conversations, user data, etc.

The `window.botpressWebChat` methods `init` and `configure` both accepts the `userId` parameter. It will override the randomly generated one.
Since the User ID allows BP to recognize the user and to continue a conversation, these should not be guessable and needs to be unique.

## Supported databases

Botpress comes with support for SQL databases out-the-box and can be accessed by:
Expand Down
22 changes: 14 additions & 8 deletions modules/channel-web/src/backend/api.ts
Expand Up @@ -19,7 +19,7 @@ export default async (bp: typeof sdk, db: Database) => {
files: 1,
fileSize: 5242880 // 5MB
},
filename: function (req, file, cb) {
filename: function(req, file, cb) {
const userId = _.get(req, 'params.userId') || 'anonymous'
const ext = path.extname(file.originalname)

Expand Down Expand Up @@ -59,7 +59,7 @@ export default async (bp: typeof sdk, db: Database) => {
contentType: multers3.AUTO_CONTENT_TYPE,
cacheControl: 'max-age=31536000', // one year caching
acl: 'public-read',
key: function (req, file, cb) {
key: function(req, file, cb) {
const userId = _.get(req, 'params.userId') || 'anonymous'
const ext = path.extname(file.originalname)

Expand All @@ -84,6 +84,7 @@ export default async (bp: typeof sdk, db: Database) => {
// ?conversationId=xxx (optional)
router.post(
'/messages/:userId',
bp.http.extractExternalToken,
asyncApi(async (req, res) => {
const { botId, userId = undefined } = req.params

Expand All @@ -106,7 +107,7 @@ export default async (bp: typeof sdk, db: Database) => {
conversationId = await db.getOrCreateRecentConversation(botId, userId, { originatesFromUserMessage: true })
}

await sendNewMessage(botId, userId, conversationId, payload)
await sendNewMessage(botId, userId, conversationId, payload, req.credentials)

return res.sendStatus(200)
})
Expand All @@ -116,6 +117,7 @@ export default async (bp: typeof sdk, db: Database) => {
router.post(
'/messages/:userId/files',
upload.single('file'),
bp.http.extractExternalToken,
asyncApi(async (req, res) => {
const { botId = undefined, userId = undefined } = req.params || {}

Expand Down Expand Up @@ -144,7 +146,7 @@ export default async (bp: typeof sdk, db: Database) => {
}
}

await sendNewMessage(botId, userId, conversationId, payload)
await sendNewMessage(botId, userId, conversationId, payload, req.credentials)

return res.sendStatus(200)
})
Expand Down Expand Up @@ -186,7 +188,7 @@ export default async (bp: typeof sdk, db: Database) => {
return /[a-z0-9-_]+/i.test(userId)
}

async function sendNewMessage(botId: string, userId: string, conversationId, payload) {
async function sendNewMessage(botId: string, userId: string, conversationId, payload, credentials: any) {
const config = await bp.config.getModuleConfigForBot('channel-web', botId)

if (!payload.text || !_.isString(payload.text) || payload.text.length > config.maxMessageLength) {
Expand Down Expand Up @@ -220,7 +222,8 @@ export default async (bp: typeof sdk, db: Database) => {
payload,
target: userId,
threadId: conversationId,
type: payload.type
type: payload.type,
credentials
})

const message = await db.appendUserMessage(botId, userId, conversationId, persistedPayload)
Expand All @@ -231,6 +234,7 @@ export default async (bp: typeof sdk, db: Database) => {

router.post(
'/events/:userId',
bp.http.extractExternalToken,
asyncApi(async (req, res) => {
const { payload = undefined } = req.body || {}
const { botId = undefined, userId = undefined } = req.params || {}
Expand All @@ -245,7 +249,8 @@ export default async (bp: typeof sdk, db: Database) => {
target: userId,
threadId: conversationId,
type: payload.type,
payload
payload,
credentials: req.credentials
})

bp.events.sendEvent(event)
Expand All @@ -255,6 +260,7 @@ export default async (bp: typeof sdk, db: Database) => {

router.post(
'/conversations/:userId/:conversationId/reset',
bp.http.extractExternalToken,
asyncApi(async (req, res) => {
const { botId, userId, conversationId } = req.params
await bp.users.getOrCreateUser('web', userId)
Expand All @@ -264,7 +270,7 @@ export default async (bp: typeof sdk, db: Database) => {
type: 'session_reset'
}

await sendNewMessage(botId, userId, conversationId, payload)
await sendNewMessage(botId, userId, conversationId, payload, req.credentials)
await bp.dialog.deleteSession(userId)
res.status(200).send({})
})
Expand Down
39 changes: 34 additions & 5 deletions modules/channel-web/src/views/web/index.jsx
Expand Up @@ -59,10 +59,6 @@ export default class Web extends React.Component {
const { options } = queryString.parse(location.search)
const { config } = JSON.parse(decodeURIComponent(options || '{}'))

this.axiosConfig = config.botId
? { baseURL: `${window.location.origin}/api/v1/bots/${config.botId}` }
: { baseURL: `${window.BOT_API_PATH}` }

this.state = {
view: null,
textToSend: '',
Expand All @@ -79,13 +75,19 @@ export default class Web extends React.Component {
messageHistory: [],
historyPosition: HISTORY_STARTING_POINT
}

this.updateAxiosConfig()
}

componentWillMount() {
this.setupSocket()
}

componentDidMount() {
if (this.state.config.userId) {
this.props.bp.events.updateVisitorId(this.state.config.userId)
}

this.setUserId()
.then(this.fetchData)
.then(() => {
Expand Down Expand Up @@ -118,9 +120,35 @@ export default class Web extends React.Component {
this.isUnmounted = true
}

updateAxiosConfig() {
const { botId, externalAuthToken } = this.state.config

this.axiosConfig = botId
? { baseURL: `${window.location.origin}/api/v1/bots/${botId}` }
: { baseURL: `${window.BOT_API_PATH}` }

if (externalAuthToken) {
this.axiosConfig = {
...this.axiosConfig,
headers: {
ExternalAuth: `Bearer ${externalAuthToken}`
}
}
}
}

changeUserId = newId => {
this.props.bp.events.updateVisitorId(newId)
this.setState({ currentConversationId: null })
this.setUserId().then(this.fetchData)
}

handleIframeApi = ({ data: { action, payload } }) => {
if (action === 'configure') {
this.setState({ config: Object.assign({}, defaultOptions, payload) })
if (payload.userId) {
this.changeUserId(payload.userId)
}
this.setState({ config: Object.assign({}, defaultOptions, payload) }, this.updateAxiosConfig)
} else if (action === 'event') {
const { type, text } = payload
if (type === 'show') {
Expand All @@ -144,6 +172,7 @@ export default class Web extends React.Component {
if (window.__BP_VISITOR_ID) {
clearInterval(interval)
this.userId = window.__BP_VISITOR_ID
window.parent.postMessage({ userId: this.userId }, '*')
resolve()
}
}, 250)
Expand Down
7 changes: 7 additions & 0 deletions src/bp/core/api.ts
@@ -1,5 +1,6 @@
import * as sdk from 'botpress/sdk'
import { WellKnownFlags } from 'core/sdk/enums'
import { NextFunction, Request, Response } from 'express'
import { inject, injectable } from 'inversify'
import Knex from 'knex'
import { Memoize } from 'lodash-decorators'
Expand Down Expand Up @@ -40,6 +41,12 @@ const http = (httpServer: HTTPServer): typeof sdk.http => {
},
async getAxiosConfigForBot(botId: string, options?: sdk.AxiosOptions): Promise<any> {
return httpServer.getAxiosConfigForBot(botId, options)
},
extractExternalToken(req: Request, res: Response, next: NextFunction): Promise<void> {
return httpServer.extractExternalToken(req, res, next)
},
decodeExternalToken(token: string): Promise<any> {
return httpServer.decodeExternalToken(token)
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/bp/core/config/botpress.config.ts
Expand Up @@ -112,6 +112,11 @@ export type BotpressConfig = {
*/
allowSelfSignup: boolean
}
/**
* External Authentication allows your backend to issue a JWT token to securely pass data to Botpress through the user
* The token is validated each time a message is sent and the content is available on `event.credentials`
*/
externalAuth: ExternalAuthConfig
}
/**
* An array of e-mails of users which will have root access to Botpress (manage users, server settings)
Expand All @@ -130,6 +135,16 @@ export type BotpressConfig = {
dataRetention?: DataRetentionConfig
}

export interface ExternalAuthConfig {
enabled: boolean
audience: string
algorithm: string
/**
* When this key is undefined, BP will try to load the public key from `data/global/pub.key`
*/
publicKey?: string
}

export interface DataRetentionConfig {
/**
* The janitor will check for expired fields at the set interval (second, minute, hour, day)
Expand Down
18 changes: 15 additions & 3 deletions src/bp/core/routers/bots/converse.ts
@@ -1,4 +1,5 @@
import { Logger } from 'botpress/sdk'
import HTTPServer from 'core/server'
import AuthService, { TOKEN_AUDIENCE } from 'core/services/auth/auth-service'
import { ConverseService } from 'core/services/converse'
import { RequestHandler, Router } from 'express'
Expand All @@ -10,7 +11,12 @@ import { checkTokenHeader } from '../util'
export class ConverseRouter extends CustomRouter {
private checkTokenHeader!: RequestHandler

constructor(logger: Logger, private converseService: ConverseService, private authService: AuthService) {
constructor(
logger: Logger,
private converseService: ConverseService,
private authService: AuthService,
private httpServer: HTTPServer
) {
super('Converse', logger, Router({ mergeParams: true }))
this.checkTokenHeader = checkTokenHeader(this.authService, TOKEN_AUDIENCE)
this.setupRoutes()
Expand All @@ -19,6 +25,7 @@ export class ConverseRouter extends CustomRouter {
setupRoutes() {
this.router.post(
'/:userId',
this.httpServer.extractExternalToken,
this.asyncMiddleware(async (req, res) => {
const { userId, botId } = req.params
const params = req.query.include
Expand All @@ -27,7 +34,7 @@ export class ConverseRouter extends CustomRouter {
return res.status(401).send("Unauthenticated converse API can only return 'responses'")
}

const rawOutput = await this.converseService.sendMessage(botId, userId, req.body)
const rawOutput = await this.converseService.sendMessage(botId, userId, req.body, req.credentials)
const formatedOutput = this.prepareResponse(rawOutput, params)

return res.json(formatedOutput)
Expand All @@ -37,9 +44,10 @@ export class ConverseRouter extends CustomRouter {
this.router.post(
'/:userId/secured',
this.checkTokenHeader,
this.httpServer.extractExternalToken,
this.asyncMiddleware(async (req, res) => {
const { userId, botId } = req.params
const rawOutput = await this.converseService.sendMessage(botId, userId, req.body)
const rawOutput = await this.converseService.sendMessage(botId, userId, req.body, req.credentials)
const formatedOutput = this.prepareResponse(rawOutput, req.query.include)

return res.json(formatedOutput)
Expand All @@ -66,6 +74,10 @@ export class ConverseRouter extends CustomRouter {
delete output.decision
}

if (!parts.includes('credentials')) {
delete output.credentials
}

return output
}
}
6 changes: 5 additions & 1 deletion src/bp/core/routers/util.ts
Expand Up @@ -16,7 +16,11 @@ import {
UnauthorizedError
} from './errors'

export type BPRequest = Request & { authUser: AuthUser | undefined; tokenUser: TokenUser | undefined }
export type BPRequest = Request & {
authUser: AuthUser | undefined
tokenUser: TokenUser | undefined
credentials: any | undefined
}

export type AsyncMiddleware = (
fn: (req: BPRequest, res: Response, next?: NextFunction | undefined) => Promise<any>
Expand Down

0 comments on commit ccaa3b6

Please sign in to comment.