diff --git a/.env.example b/.env.example index 5564dd6..02a6778 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,3 @@ STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= STRIPE_API_VERSION=2018-05-21 - -# JSON web token (JWT) secret: this keeps our app's user authentication secure -# This secret should be a random 20-character string of characters, e.g. 'oj2130sdjk120asdim2u2' -JWT_SECRET= diff --git a/README.md b/README.md index a59f364..9f99f7f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Typographic is a complete, full-stack example of a Stripe Billing integration: 🌏|**Vue.js frontend.** Single-page [Vue](https://vuejs.org) app demonstrating how to use Elements in a component-based web framework. ☕️|**Node.js backend.** An [Express](https://expressjs.com/) server manages billing and user data between the database and Stripe's API. 📦|**Database support.** Uses [Knex.js](http://knexjs.org/) and [SQLite](https://www.sqlite.org/index.html) (by default) to demonstrate a data modeling pattern for the [Billing](https://stripe.com/docs/billing/quickstart) API. -🔑|**User authentication.** JSON web tokens ([JWT](https://jwt.io/)) and an Express authentication scheme are included for user login and registration. +🔑|**User authentication.** An Express authentication scheme is included for user login and registration. ## Stripe Billing Integration @@ -66,10 +66,7 @@ Install dependencies using npm (or yarn): npm install ``` -Copy the example .env file. You'll need to fill out two details: - -- Your [Stripe API keys](https://dashboard.stripe.com/account/apikeys) -- A random 20-character string to keep user authentication secure (using [JSON Web Tokens](https://jwt.io)) +Copy the example .env file, and fill in your [Stripe API keys](https://dashboard.stripe.com/account/apikeys). ``` cp .env.example .env diff --git a/client/src/auth.js b/client/src/auth.js index d7547a8..28a06a3 100644 --- a/client/src/auth.js +++ b/client/src/auth.js @@ -2,33 +2,17 @@ * auth.js * Stripe Billing demo. Created by Michael Glukhovsky (@mglukhovsky). * - * Frontend authentication: this manages our user authentication and JSON web - * token (JWT) storage in the browser. + * Frontend authentication: this manages our user authentication and session + * token storage in the browser. */ import axios from 'axios'; import Vue from 'vue'; -import jwtDecode from 'jwt-decode'; export default { - // Try to retrieve our JSON web token (JWT) from the session storage + // Try to retrieve our session token token: sessionStorage.getItem('token'), - // Check our token to make sure it's valid - hasValidToken() { - if (this.token) { - // Decode the token and check its data. The `exp` property is a timestamp - // when this token will expire. - const decoded = jwtDecode(this.token); - if (decoded.exp > Date.now()) { - // The token expired, so log out the user. - this.logout(); - return false; - } - return true; - } - return false; - }, - // Store the JWT and user credentials + // Store the session token setToken(token) { // Add the token to our session this.token = token; @@ -36,21 +20,30 @@ export default { // Include a header with outgoing requests this.setHeader(); }, - // Log out the user - logout() { - // Remove the HTTP header that include the JWT token + // Remove the session token locally + clearToken() { + // Remove the HTTP header that include the session token delete axios.defaults.headers.common['Authorization']; // Delete the token from our session sessionStorage.removeItem('token'); this.token = null; }, + // Log out the user + async logout() { + try { + await axios.post('/auth/logout'); + } catch (e) { + console.warn(e); + } + this.clearToken(); + }, // Check if the user is logged in loggedIn() { return !!this.token; }, - // Instruct Vue to include a header with the JWT in every request + // Instruct Vue to include a header with the session token in every request setHeader() { - if (this.hasValidToken()) { + if (this.loggedIn()) { axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`; } }, diff --git a/client/src/components/Account.vue b/client/src/components/Account.vue index 332c498..aab84e5 100644 --- a/client/src/components/Account.vue +++ b/client/src/components/Account.vue @@ -83,6 +83,7 @@

Want to increase your included requests?

+
diff --git a/client/src/components/AppNav.vue b/client/src/components/AppNav.vue index 5452c6b..b802caa 100644 --- a/client/src/components/AppNav.vue +++ b/client/src/components/AppNav.vue @@ -41,7 +41,7 @@ export default { this.showingDropdown = !this.showingDropdown; e.stopPropagation(); }, - logout: function() { + logout: async function() { store.logout(); }, }, diff --git a/client/src/components/Login.vue b/client/src/components/Login.vue index ae810d0..4dcc646 100644 --- a/client/src/components/Login.vue +++ b/client/src/components/Login.vue @@ -88,13 +88,18 @@ export default { // Update the button state this.loggingIn = true + + // clear any existing local session info + store.authenticated = false; + auth.clearToken() + try { // Server: create a new account / log in with the provided credentials - // - returns a JWT token for the user. + // - returns a session token for the user. const authResponse = await axios.post(apiRoute, {email, password}); // Authentication success store.authenticated = true; - // Store the JWT from the server + // Store the token from the server auth.setToken(authResponse.data.token); // Local store: update the user from the API const updatedUser = await store.fetchUser(); diff --git a/client/src/components/Pricing.vue b/client/src/components/Pricing.vue index 7768904..de04d5d 100644 --- a/client/src/components/Pricing.vue +++ b/client/src/components/Pricing.vue @@ -29,7 +29,8 @@

plan and use requests, your bill will be . -

+

+ diff --git a/client/src/store.js b/client/src/store.js index e4cb158..f7a55cf 100644 --- a/client/src/store.js +++ b/client/src/store.js @@ -109,7 +109,7 @@ const store = { defaultFontSample: '', fontSample: '', fontSize: '40', - authenticated: auth.hasValidToken(), + authenticated: auth.loggedIn(), email: '', subscription: null, source: null, @@ -223,7 +223,7 @@ store.defaultFontSample = store.randomQuote(); store.fontSample = store.defaultFontSample; // Get the Stripe key store.getStripeKey(); -// If we have a JWT stored, add a header all requests identifying the user +// If we have a session token stored, add a header all requests identifying the user auth.setHeader(); export default store; diff --git a/config.js b/config.js index bb746ba..26da7b7 100644 --- a/config.js +++ b/config.js @@ -36,7 +36,7 @@ module.exports = { publicKey: process.env.STRIPE_PUBLISHABLE_KEY, secretKey: process.env.STRIPE_SECRET_KEY, }, - // Configuration for Knex using a s + // Configuration for Knex using a sqlite3 database database: { client: 'sqlite3', connection: { @@ -45,6 +45,4 @@ module.exports = { // Use `null` for any default values in SQLite useNullAsDefault: true, }, - // Secret for generating JSON web tokens: this can be any very long random string - jwtSecret: process.env.JWT_SECRET, }; diff --git a/package-lock.json b/package-lock.json index c011124..ebd2e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -817,11 +817,6 @@ "parse-asn1": "^5.0.0" } }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, "buffer-es6": { "version": "4.9.3", "resolved": "https://registry.npmjs.org/buffer-es6/-/buffer-es6-4.9.3.tgz", @@ -1527,14 +1522,6 @@ "jsbn": "~0.1.0" } }, - "ecdsa-sig-formatter": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", - "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", - "requires": { - "safe-buffer": "^5.0.1" - } - }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3549,29 +3536,6 @@ "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", "dev": true }, - "jsonwebtoken": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz", - "integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==", - "requires": { - "jws": "^3.1.5", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1" - }, - "dependencies": { - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" - } - } - }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -3590,30 +3554,6 @@ } } }, - "jwa": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz", - "integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.10", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz", - "integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==", - "requires": { - "jwa": "^1.1.5", - "safe-buffer": "^5.0.1" - } - }, - "jwt-decode": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-2.2.0.tgz", - "integrity": "sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=" - }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -3969,41 +3909,11 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, - "lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" - }, - "lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" - }, - "lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" - }, - "lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" - }, "lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" }, - "lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" - }, - "lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" - }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", diff --git a/package.json b/package.json index 4b75d78..14af842 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "connect-history-api-fallback": "^1.5.0", "dotenv": "^5.0.1", "express": "^4.16.3", - "jsonwebtoken": "^8.3.0", - "jwt-decode": "^2.2.0", "knex": "^0.14.6", "mysql": "^2.16.0", "parse5": "^2.2.3", diff --git a/server/middleware/session.js b/server/middleware/session.js new file mode 100644 index 0000000..fd3cbc4 --- /dev/null +++ b/server/middleware/session.js @@ -0,0 +1,61 @@ +/** + * session.js + * Stripe Billing demo. Created by Michael Glukhovsky (@mglukhovsky). + * + * This Express middleware (exported as `middleware`) checks if the incoming + * request includes a valid session token in the `Authorization` header. If + * so, the associated account and customer IDs are set in `locals`. If not, + * the request is sent to the error handler. + * + * a `getRequestToken` utility function is also exported for use in the + * `/logout` route. + */ +'use strict'; + +const db = require('../database'); + +function oneHourAgo() { + return (Date.now() / 1000) - (60 * 60); +} + +function getRequestToken(req) { + // Check if an `Authorization` header was included + const header = req.headers.authorization + + if (!header || !header.startsWith('Bearer')) { + // Failed: no token provided + throw new Error('No `Authorization` header provided.') + } + return header.replace(/^Bearer /, '') +} + +async function middleware(req, res, next) { + try { + // Get session token and look up associated data + const token = getRequestToken(req) + const [session] = await db('sessions') + .where('token', token) + .andWhere('timestamp', '>', oneHourAgo()); + + if (!session) { + throw new Error(`Session not found for token: ${token}`) + } + + // Update session timestamp + try { + await db('sessions').where('token', token).update('timestamp', Date.now()/1000) + } catch(e) { + // unexpected, but not fatal, so continue + } + + // Success: include session data in the request + res.locals.accountId = session.accountId + res.locals.customerId = session.customerId + return next() + + } catch (err) { + err.authFailed = true + return next(err) + } +} +module.exports = {middleware, getRequestToken} diff --git a/server/middleware/verifyToken.js b/server/middleware/verifyToken.js deleted file mode 100644 index 2aebc16..0000000 --- a/server/middleware/verifyToken.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * verifyToken.js - * Stripe Billing demo. Created by Michael Glukhovsky (@mglukhovsky). - * - * This Express middleware checks if the incoming request includes a valid - * JSON web token (JWT) for an authenticated user before allowing the request - * on the matching route. - */ -'use strict'; - -const jwt = require('jsonwebtoken') -const config = require('../../config') - -module.exports = (req, res, next) => { - // Check if an `Authorization` header was included - const header = req.headers.authorization - if (!header || !header.startsWith('Bearer')) { - // Failed: no token provided - const err = new Error('No `Authorization` header provided.') - err.authFailed = true - return next(err) - } - - const token = header.replace(/^Bearer /, '') - let account = null - try { - account = jwt.verify(token, config.jwtSecret) - } catch (e) { - // Failed: wrong token - const err = new Error('JWT token verification failed.') - err.authFailed = true - return next(err) - } - - // Failed: no account found in decoded data - if (!account.data.accountId) { - const err = new Error('JWT is missing account data.') - err.authFailed = true - return next(err) - } - - // Success: include decoded data in the request - res.locals.accountId = account.data.accountId - res.locals.customerId = account.data.customerId - return next() -} diff --git a/server/routes/auth.js b/server/routes/auth.js index 65d0794..4f47bd9 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -8,10 +8,11 @@ const config = require('../../config'); const router = require('express').Router(); -const jwt = require('jsonwebtoken'); const Account = require('../models/Account'); const Customer = require('../models/Customer'); const db = require('../database'); +const crypto = require('crypto'); +const getRequestToken = require('../middleware/session').getRequestToken; // Sign up a new user router.post('/signup', async (req, res, next) => { @@ -32,10 +33,9 @@ router.post('/signup', async (req, res, next) => { } const account = await Account.create(email, password); - // Success: generate a JSON web token and respond with the JWT - return res.json({token: generateToken(account.id, account.customer.id)}); + // Success: generate a session record with a random token and respond with the token + return res.json({token: await createSession(account.id, account.customer.id) }) } catch (e) { - console.log(e) return next(new Error(e)); } }); @@ -56,11 +56,12 @@ router.post('/login', async (req, res, next) => { const verifiedPassword = await account.comparePassword(password); if (verifiedPassword) { - // Success: generate and respond with the JWT - return res.json({token: generateToken(account.id, customer.id)}); + // Success: generate a session record with a random token and respond with the token + return res.json({token: await createSession(account.id, customer.id) }) } } } catch (e) { + console.log(e) return next(new Error(e)); } // Unauthorized (HTTP 401) @@ -70,19 +71,31 @@ router.post('/login', async (req, res, next) => { return next(err); }); -// Generates a signed JWT that encodes a account ID -// This function requires: -// - accountId: account to include in the token -// - customerID: customer to include in the token -function generateToken(accountId, customerId) { - // Include some data and an expiration timestamp in the JWT - return jwt.sign( - { - exp: Math.floor(Date.now() / 1000) + 60 * 60, // This key expires in 1 hour - data: {accountId, customerId}, - }, - config.jwtSecret - ); +// Log a user out by removing their session record +router.post('/logout', async (req, res, next) => { + await removeSessions({ token: getRequestToken(req) }); +}); + +// Create a database record mapping a random session token +// to an account and customer. +async function createSession(accountId, customerId) { + // Remove any existing sessions for this account. This is partially just to + // avoid filling the database with stale entries, but preventing a user from + // having more than one simultaneous session also provides a potential + // security benefit in that stolen sessions can't persist past a new + // legitimate login. + await removeSessions({accountId}); + + // Create the new session + const token = crypto.randomBytes(16).toString('base64'); + const timestamp = Date.now() / 1000; + await db('sessions').insert({ token, accountId, customerId, timestamp }); + return token +} + +// Remove all session records associated with a particular token, accountId, etc. +async function removeSessions(criteria) { + return db('sessions').where(criteria).del(); } module.exports = router; diff --git a/server/routes/stripe.js b/server/routes/stripe.js index 8634aaa..c2e9b81 100644 --- a/server/routes/stripe.js +++ b/server/routes/stripe.js @@ -17,7 +17,7 @@ const config = require('../../config'); const router = require('express').Router(); -const verifyToken = require('../middleware/verifyToken'); +const verifySession = require('../middleware/session').middleware; const stripe = require('stripe')(config.stripe.secretKey); const Account = require('../models/Account'); const Customer = require('../models/Customer'); @@ -29,7 +29,7 @@ router.get('/environment', async (req, res, next) => { }); // Get the account for this user -router.get('/account', verifyToken, async (req, res, next) => { +router.get('/account', verifySession, async (req, res, next) => { const {accountId} = res.locals; try { // Get this account as JSON @@ -47,7 +47,7 @@ router.get('/account', verifyToken, async (req, res, next) => { }); // Get the subscription for the current user -router.get('/subscription', verifyToken, async (req, res, next) => { +router.get('/subscription', verifySession, async (req, res, next) => { const {customerId} = res.locals; try { // Get the subscription for this customer as JSON @@ -60,7 +60,7 @@ router.get('/subscription', verifyToken, async (req, res, next) => { }); // Create a subscription. -router.post('/subscription', verifyToken, async (req, res, next) => { +router.post('/subscription', verifySession, async (req, res, next) => { const {customerId} = res.locals; // This route expects the body parameters: // - plan: the primary plan for the subscription @@ -83,7 +83,7 @@ router.post('/subscription', verifyToken, async (req, res, next) => { }); // Update a subscription. -router.patch('/subscription', verifyToken, async (req, res, next) => { +router.patch('/subscription', verifySession, async (req, res, next) => { const {customerId} = res.locals; // This route expects the body parameters: // - plan: the primary plan for the subscription @@ -105,7 +105,7 @@ router.patch('/subscription', verifyToken, async (req, res, next) => { }); // Cancel the user's subscription -router.delete('/subscription', verifyToken, async (req, res, next) => { +router.delete('/subscription', verifySession, async (req, res, next) => { const {customerId} = res.locals; try { // Get the subscription for this customer @@ -119,7 +119,7 @@ router.delete('/subscription', verifyToken, async (req, res, next) => { }); // Request invoices via email for the user -router.post('/invoices/subscribe', verifyToken, async (req, res, next) => { +router.post('/invoices/subscribe', verifySession, async (req, res, next) => { const {customerId} = res.locals; try { // Get the customer's subscription @@ -132,7 +132,7 @@ router.post('/invoices/subscribe', verifyToken, async (req, res, next) => { }); // Get the upcoming invoice for the user -router.get('/invoices/upcoming', verifyToken, async (req, res, next) => { +router.get('/invoices/upcoming', verifySession, async (req, res, next) => { const {customerId} = res.locals; try { // Get the customer's subscription @@ -154,7 +154,7 @@ router.get('/invoices/upcoming', verifyToken, async (req, res, next) => { // Update the fonts used by this account // - fonts: a comma-separated string of font ids -router.post('/fonts', verifyToken, async (req, res, next) => { +router.post('/fonts', verifySession, async (req, res, next) => { const {customerId} = res.locals; const {fonts} = req.body; try { @@ -170,7 +170,7 @@ router.post('/fonts', verifyToken, async (req, res, next) => { // Record usage for a metered subscription // - numRequests: the number of requests to record -router.post('/usage', verifyToken, async (req, res, next) => { +router.post('/usage', verifySession, async (req, res, next) => { const {customerId} = res.locals; // This route expects the body parameters: // - numRequests: the number of requests for this usage period @@ -193,7 +193,7 @@ router.post('/usage', verifyToken, async (req, res, next) => { }); // Update the payment source -router.post('/source', verifyToken, async (req, res, next) => { +router.post('/source', verifySession, async (req, res, next) => { const {customerId} = res.locals; // This route expects the body parameters: // - source: Stripe Source (created by Stripe Elements on the frontend) @@ -216,7 +216,7 @@ router.post('/source', verifyToken, async (req, res, next) => { }); // Delete the payment source -router.delete('/source', verifyToken, async (req, res, next) => { +router.delete('/source', verifySession, async (req, res, next) => { const {customerId} = res.locals; try { // Get this customer diff --git a/setup.js b/setup.js index a89ecf4..da4830d 100644 --- a/setup.js +++ b/setup.js @@ -39,7 +39,7 @@ const {exec} = require('child_process'); module.exports = { running: false, async checkTables() { - for (let table of ['accounts', 'customers', 'subscriptions', 'plans']) { + for (let table of ['accounts', 'customers', 'subscriptions', 'plans', 'sessions']) { const hasTable = await knex.schema.hasTable(table); if (!hasTable) { return false; @@ -91,6 +91,7 @@ async function dropTables() { knex.schema.dropTableIfExists('customers'), knex.schema.dropTableIfExists('subscriptions'), knex.schema.dropTableIfExists('plans'), + knex.schema.dropTableIfExists('sessions'), ]); } @@ -141,6 +142,12 @@ async function createTables() { t.integer('amount'); t.float('included'); }), + knex.schema.createTable('sessions', t => { + t.string('token'); + t.string('accountId'); + t.string('customerId'); + t.integer('timestamp'); + }), ]); console.log('✅ Database is ready'); }