Merge pull request #43 from EbookFoundation/feature/sign-webhook-requests

Feature/sign webhook requests
pull/45/head
Theodore Kluge 2019-03-05 19:57:50 -05:00 committed by GitHub
commit 17338888de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 223 additions and 53 deletions

View File

@ -6,7 +6,7 @@
*/
const HttpError = require('../errors/HttpError')
const { asyncRead } = require('../util')
const { asyncRead, hmacSign } = require('../util')
const request = require('request')
const uriRegex = /^(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
@ -64,7 +64,9 @@ async function sendUpdatesAsync (id) {
const targets = await TargetUrl.find()
if (!book) return
for (const i in targets) {
try {
const item = targets[i]
const user = await User.findOne({ id: item.user })
const { author: fAuthor, publisher: fPublisher, title: fTitle, isbn: fIsbn, url } = item
const { author: bAuthor, publisher: bPublisher, title: bTitle, isbn: bIsbn } = book
sails.log('sending ' + book.id + ' info to ' + url)
@ -86,9 +88,14 @@ async function sendUpdatesAsync (id) {
} catch (e) {
content = await opdsHelper.book2opds(book)
}
const timestamp = Date.now()
request.post({
url: url,
headers: { 'User-Agent': 'RoE-aggregator' },
headers: {
'User-Agent': 'RoE-aggregator',
'X-RoE-Signature': hmacSign(user.signing_secret, timestamp, content),
'X-RoE-Request-Timestamp': timestamp
},
body: content,
json: true
}, function (err, httpResp, body) {
@ -97,5 +104,8 @@ async function sendUpdatesAsync (id) {
}
})
}
} catch (e) {
sails.log(`error: ${e.message}\n${e.stack}`)
}
}
}

View File

@ -4,6 +4,8 @@
* @description :: Server-side logic for managing Users
* @help :: See http://links.sailsjs.org/docs/controllers
*/
const { generateToken } = require('../util')
const HttpError = require('../errors/HttpError')
module.exports = {
/**
@ -35,5 +37,14 @@ module.exports = {
me: function (req, res) {
res.json(req.user)
},
regenerateSigningSecret: async function (req, res) {
try {
const user = await User.updateOne({ id: req.user.id }, { signing_secret: await generateToken({ bytes: 24 }) })
return res.json(user)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
}
}

View File

@ -1,7 +1,7 @@
// api/helpers/passport.js
// from https://github.com/trailsjs/sails-auth/blob/master/api/services/passport.js
const url = require('url')
const { generateToken } = require('../util')
module.exports = {
friendlyName: 'Load PassportHelper',
@ -132,7 +132,7 @@ function PassportHelper () {
user = await User.findOne({ email: userAttrs.email })
}
if (!user) {
user = await User.create(userAttrs).fetch()
user = await User.create({ userAttrs, signing_secret: await generateToken({ bytes: 24 }) }).fetch()
}
await Passport.create({
...q,

View File

@ -1,13 +1,4 @@
const crypto = require('crypto')
function generateToken ({ bytes, base }) {
return new Promise((resolve, reject) => {
crypto.randomBytes(bytes, (err, buf) => {
if (err) reject(err)
else resolve(buf.toString(base || 'base64'))
})
})
}
const { generateToken } = require('../util')
module.exports = {
attributes: {

View File

@ -19,7 +19,8 @@ module.exports = {
email: {
type: 'string'
},
admin: 'boolean'
admin: 'boolean',
signing_secret: 'string'
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗

View File

@ -1,3 +1,6 @@
const crypto = require('crypto')
const APP_VERSION = 'v0'
function asyncRead (adapter, helper, storage) {
return new Promise((resolve, reject) => {
adapter.read(storage, (err, data) => {
@ -17,6 +20,23 @@ function asyncRead (adapter, helper, storage) {
})
}
module.exports = {
asyncRead
function generateToken ({ bytes, base }) {
return new Promise((resolve, reject) => {
crypto.randomBytes(bytes, (err, buf) => {
if (err) reject(err)
else resolve(buf.toString(base || 'base64'))
})
})
}
function hmacSign (secret, timestamp, body) {
const value = `${APP_VERSION}:${timestamp}:${body}`
const hmac = crypto.createHmac('sha256', secret.toString()).update(value, 'utf-8').digest('hex')
return `${APP_VERSION}=${hmac}`
}
module.exports = {
asyncRead,
generateToken,
hmacSign
}

View File

@ -256,3 +256,20 @@ export const saveFile = data => (dispatch) => {
var blob = new Blob([data], { type: 'text/plain;charset=utf-8' })
FileSaver.saveAs(blob, data)
}
export const regenerateSigningSecret = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: user } = await Ajax.patch({
url: getPath('/api/me/regenerate_signing_secret')
})
dispatch(setUser(user))
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}

View File

@ -17,6 +17,7 @@ function getSVG (icon) {
case 'eye-close': return ' <path d="M11.83,9L15,12.16C15,12.11 15,12.05 15,12A3,3 0 0,0 12,9C11.94,9 11.89,9 11.83,9M7.53,9.8L9.08,11.35C9.03,11.56 9,11.77 9,12A3,3 0 0,0 12,15C12.22,15 12.44,14.97 12.65,14.92L14.2,16.47C13.53,16.8 12.79,17 12,17A5,5 0 0,1 7,12C7,11.21 7.2,10.47 7.53,9.8M2,4.27L4.28,6.55L4.73,7C3.08,8.3 1.78,10 1,12C2.73,16.39 7,19.5 12,19.5C13.55,19.5 15.03,19.2 16.38,18.66L16.81,19.08L19.73,22L21,20.73L3.27,3M12,7A5,5 0 0,1 17,12C17,12.64 16.87,13.26 16.64,13.82L19.57,16.75C21.07,15.5 22.27,13.86 23,12C21.27,7.61 17,4.5 12,4.5C10.6,4.5 9.26,4.75 8,5.2L10.17,7.35C10.74,7.13 11.35,7 12,7Z" />'
case 'shield-check': return '<path d="M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1Z" />'
case 'alert-circle': return '<path d="M13,13H11V7H13M13,17H11V15H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />'
case 'refresh': return '<path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" />'
default: return icon || 'missing icon prop'
}
}

View File

@ -8,12 +8,19 @@ export default class ConfirmIconButton extends React.Component {
confirmed: false
}
this.onClick = this.onClick.bind(this)
this.timer = null
}
onClick (e) {
e.stopPropagation()
if (this.state.confirmed) {
clearTimeout(this.timer)
this.setState({ confirmed: false })
this.props.onClick(e)
} else {
this.setState({ confirmed: true })
this.timer = setTimeout(() => {
this.setState({ confirmed: false })
}, 4000)
}
}
render () {

View File

@ -2,6 +2,7 @@
import React from 'react'
import IconButton from '../components/IconButton'
import ConfirmIconButton from '../containers/ConfirmIconButton'
import UnderlineInput from '../components/UnderlineInput'
import './listitem.scss'
import { changeUrlField, setUrl, removeUrl, setEditingUri } from '../actions'
@ -32,7 +33,7 @@ class UriListItem extends React.Component {
<span className='label'>Filters</span>
<span className='value'>{['publisher', 'title', 'author', 'isbn'].reduce((a, x) => a + (this.props.item[x] ? 1 : 0), 0) || 'None'}</span>
</div>
<IconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
</li>
)
}
@ -41,7 +42,7 @@ class UriListItem extends React.Component {
<li className='uri-list-item flex-container flex-vertical editing' onClick={(e) => this.cancelEvent(e, false)}>
<header className='flex-container' onClick={(e) => this.cancelEvent(e, null)}>
<h3 className='flex'>Editing: {this.props.item.url}</h3>
<IconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
<ConfirmIconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.item.id))} />
</header>
<div className='settings'>
<UnderlineInput

View File

@ -1,5 +1,3 @@
'use strict'
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react-router-dom'
@ -7,8 +5,10 @@ import Progress from './components/Progress'
import UnderlineInput from './components/UnderlineInput'
import UriListItem from './containers/UriListItem'
import PublisherListItem from './containers/PublisherListItem'
import IconButton from './components/IconButton'
import ConfirmIconButton from './containers/ConfirmIconButton'
import reducer from './reducers'
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher, regenerateSigningSecret } from './actions'
import '../styles/index.scss'
@ -23,8 +23,10 @@ class App extends React.Component {
id: '',
email: '',
password: '',
currentPassword: ''
currentPassword: '',
signing_secret: ''
},
signingSecretShown: false,
urls: [],
publishers: [],
newPublisher: { name: '', url: '' },
@ -39,6 +41,7 @@ class App extends React.Component {
this.saveUser = this.saveUser.bind(this)
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
this.setPublisherValue = this.setPublisherValue.bind(this)
this.toggleRevealSecret = this.toggleRevealSecret.bind(this)
}
dispatch (action) {
if (!action) throw new Error('dispatch: missing action')
@ -100,6 +103,11 @@ class App extends React.Component {
item={item} />)
})
}
toggleRevealSecret () {
this.setState({
signingSecretShown: !this.state.signingSecretShown
})
}
render () {
return (
<Router>
@ -196,6 +204,17 @@ class App extends React.Component {
<button className='btn' onClick={this.saveUser}>Save</button>
</div>
</section>
<section className='details'>
<div className='row'>
<h3>Signing secret</h3>
<h4>RoE signs the requests we send to you using this unique secret. Confirm that each request comes from RoE by verifying its unique signature.</h4>
<div className='flex-container'>
<input className='flex' defaultValue={this.state.user.signing_secret} readOnly type={this.state.signingSecretShown ? 'text' : 'password'} />
<IconButton onClick={this.toggleRevealSecret} icon={this.state.signingSecretShown ? 'eye-close' : 'eye'} />
<ConfirmIconButton onClick={() => this.dispatch(regenerateSigningSecret())} icon={'refresh'} />
</div>
</div>
</section>
</div>
)} />

View File

@ -46,13 +46,32 @@
list-style: none;
// overflow: hidden;
}
.inputs {
.inputs,
.details {
padding: 20px 14px;
.buttons {
margin-top: 14px;
text-align: right;
}
input[readonly] {
background: transparent;
border: none;
outline: none;
font-family: monospace;
}
.row {
h4 {
font-size: .8em;
font-weight: normal;
color: $black-2;
}
h3,
h4 {
margin: 10px 0;
}
}
}
&.working {
& > .progress {

View File

@ -22,7 +22,8 @@ module.exports.policies = {
UserController: {
'*': true,
update: [ 'sessionAuth' ],
me: [ 'sessionAuth' ]
me: [ 'sessionAuth' ],
regenerateSigningSecret: [ 'sessionAuth' ]
},
AuthController: {

View File

@ -64,6 +64,7 @@ module.exports.routes = {
'GET /api/me': 'UserController.me',
'PATCH /api/me': 'UserController.edit',
'PATCH /api/me/regenerate_signing_secret': 'UserController.regenerateSigningSecret',
'POST /auth/:provider': 'AuthController.callback',
'POST /auth/:provider/:action': 'AuthController.callback',

View File

@ -143,6 +143,8 @@ The server will send a POST request with the following body to the provided URL
```
HTTP Headers:
User-Agent: RoE-aggregator
X-Roe-Request-Timestamp: number
X-Roe-Signature: string
HTTP Body:
{

44
docs/webhooks.md Normal file
View File

@ -0,0 +1,44 @@
# River of Ebooks signed requests
## Information on how to verify that requests are sent by RoE
1. Grab your Signing Secret from the bottom of the 'My account' page. In this example, the Signing Secret is `919ac0b6c07b50`. Additionally, extract the raw request body from the request.
```js
signing_secret = 'ROE_SIGNING_SECRET' // set this as an environment variable
>>> 919ac0b6c07b50
request_body = request.body()
>>> {"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ...
```
2. Extract the timestamp header (`1551832182955` in this example). The signature depends on the timestamp to protect against replay attacks. While you're extracting the timestamp, check to make sure that the request occurred recently. In this example, we verify that the timestamp does not differ from local time by more than five minutes.
```js
timestamp = request.headers['X-RoE-Request-Timestamp']
>>> 1551832182955
if absolute_value(time.time() - timestamp) > 60 * 5:
# The request timestamp is more than five minutes from local time.
# It could be a replay attack, so let's ignore it.
return
```
3. Concatenate the version number (`v0`), the timestamp (`1551832182955`), and the request body (`{"metadata":{"@type":"http...`) together, using a colon (`:`) as a delimiter.
```js
sig_basestring = 'v0:' + timestamp + ':' + request_body
>>> 'v0:1551832182955:{"metadata":{"@type":"http://schema.org/Book","title": "Moby-Dick" ...'
```
4. Then hash the resulting string, using the signing secret as a key, and take the hex digest of the hash. In this example, we compute a hex digest of `1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`. The full signature is formed by prefixing the hex digest with `v0=`, to make `v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23`.
```js
my_signature = 'v0=' + hmac.compute_hash_sha256(
signing_secret,
sig_basestring
).hexdigest()
>>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23'
```
5. Compare the resulting signature to the header on the request.
```js
signature = request.headers['X-RoE-Signature']
>>> 'v0=1d37b59f919ac0b6c07b50484091ab1375063ee0913ea728c23'
if (hmac.compare(my_signature, signature)) {
deal_with_request(request)
}
```

View File

@ -0,0 +1,25 @@
const { generateToken } = require('../api/util')
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('user', t => {
t.string('signing_secret')
}),
initUserSecrets(knex, Promise)
])
}
exports.down = function (knex, Promise) {
return Promise.all([
knex.schema.table('user', t => {
t.dropColumns('signing_secret')
})
])
}
async function initUserSecrets (knex, Promise) {
const users = await knex('user').whereNull('signing_secret')
for (const user of users) {
await knex('user').where({ id: user.id }).update({ signing_secret: generateToken({ bytes: 24 }) })
}
}