diff --git a/api/controllers/BooksController.js b/api/controllers/BooksController.js index 9848d1a..d59eeb9 100644 --- a/api/controllers/BooksController.js +++ b/api/controllers/BooksController.js @@ -64,44 +64,48 @@ async function sendUpdatesAsync (id) { const targets = await TargetUrl.find() if (!book) return for (const i in targets) { - 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) + 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) - if (uriRegex.test(url)) { - if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue - if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue - if (fTitle && !((bTitle || '').includes(fTitle))) continue - if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue + if (uriRegex.test(url)) { + if (fAuthor && !((bAuthor || '').includes(fAuthor))) continue + if (fPublisher && !((bPublisher || '').includes(fPublisher))) continue + if (fTitle && !((bTitle || '').includes(fTitle))) continue + if (fIsbn && !((bIsbn || '').includes(fIsbn))) continue - let content - const skipperConfig = sails.config.skipperConfig - const adapterConfig = { ...skipperConfig, adapter: undefined } - const skipperAdapter = skipperConfig.adapter(adapterConfig) - const opdsHelper = await sails.helpers.opds() - try { - if (!book.storage.length) throw new Error('missing book opds file') - content = await asyncRead(skipperAdapter, opdsHelper, book.storage) - } catch (e) { - content = await opdsHelper.book2opds(book) - } - const timestamp = Date.now() - request.post({ - url: url, - 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) { - if (err) { - sails.log(`error: failed to send book ${id} to ${url}`) + let content + const skipperConfig = sails.config.skipperConfig + const adapterConfig = { ...skipperConfig, adapter: undefined } + const skipperAdapter = skipperConfig.adapter(adapterConfig) + const opdsHelper = await sails.helpers.opds() + try { + if (!book.storage.length) throw new Error('missing book opds file') + content = await asyncRead(skipperAdapter, opdsHelper, book.storage) + } catch (e) { + content = await opdsHelper.book2opds(book) } - }) + const timestamp = Date.now() + request.post({ + url: url, + 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) { + if (err) { + sails.log(`error: failed to send book ${id} to ${url}`) + } + }) + } + } catch (e) { + sails.log(`error: ${e.message}\n${e.stack}`) } } } diff --git a/api/controllers/UserController.js b/api/controllers/UserController.js index fa85911..d7e0fb0 100644 --- a/api/controllers/UserController.js +++ b/api/controllers/UserController.js @@ -41,7 +41,7 @@ module.exports = { regenerateSigningSecret: async function (req, res) { try { - const user = await User.update({ id: req.user.id }, { signing_secret: generateToken({ bytes: 24 }) }).fetch() + 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) diff --git a/api/helpers/passport.js b/api/helpers/passport.js index 40fead1..e918a32 100644 --- a/api/helpers/passport.js +++ b/api/helpers/passport.js @@ -132,7 +132,7 @@ function PassportHelper () { user = await User.findOne({ email: userAttrs.email }) } if (!user) { - user = await User.create({ userAttrs, signing_secret: generateToken({ bytes: 24 }) }).fetch() + user = await User.create({ userAttrs, signing_secret: await generateToken({ bytes: 24 }) }).fetch() } await Passport.create({ ...q, diff --git a/api/util/index.js b/api/util/index.js index 035e6b9..aab33e3 100644 --- a/api/util/index.js +++ b/api/util/index.js @@ -31,8 +31,8 @@ function generateToken ({ bytes, base }) { function hmacSign (secret, timestamp, body) { const value = `${APP_VERSION}:${timestamp}:${body}` - const hmac = crypto.createHmac('sha256', secret).update(value, 'utf-8').digest('hex') - return hmac + const hmac = crypto.createHmac('sha256', secret.toString()).update(value, 'utf-8').digest('hex') + return `${APP_VERSION}=${hmac}` } module.exports = { diff --git a/assets/js/containers/ConfirmIconButton.js b/assets/js/containers/ConfirmIconButton.js index 1b33f85..d7a6d09 100644 --- a/assets/js/containers/ConfirmIconButton.js +++ b/assets/js/containers/ConfirmIconButton.js @@ -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 () { diff --git a/assets/js/containers/UriListItem.js b/assets/js/containers/UriListItem.js index a54c4f0..1b5b22e 100644 --- a/assets/js/containers/UriListItem.js +++ b/assets/js/containers/UriListItem.js @@ -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 { Filters {['publisher', 'title', 'author', 'isbn'].reduce((a, x) => a + (this.props.item[x] ? 1 : 0), 0) || 'None'} - this.props.dispatch(removeUrl(this.props.item.id))} /> + this.props.dispatch(removeUrl(this.props.item.id))} /> ) } @@ -41,7 +42,7 @@ class UriListItem extends React.Component {
  • this.cancelEvent(e, false)}>
    this.cancelEvent(e, null)}>

    Editing: {this.props.item.url}

    - this.props.dispatch(removeUrl(this.props.item.id))} /> + this.props.dispatch(removeUrl(this.props.item.id))} />

    Signing secret

    +

    RoE signs the requests we send to you using this unique secret. Confirm that each request comes from RoE by verifying its unique signature.

    - + - this.dispatch(regenerateSigningSecret())} icon={'refresh'} /> + this.dispatch(regenerateSigningSecret())} icon={'refresh'} />
    diff --git a/assets/styles/index.scss b/assets/styles/index.scss index b975ff4..7f13cdd 100644 --- a/assets/styles/index.scss +++ b/assets/styles/index.scss @@ -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 { diff --git a/docs/api.md b/docs/api.md index a1099c5..8b9ea69 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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: { diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..51eba4b --- /dev/null +++ b/docs/webhooks.md @@ -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) +} +```