Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace JWT authentication with database-backed sessions #9

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Server: implement db-backed sessions and logout endpoint.
  • Loading branch information
rlk-stripe committed Sep 17, 2018
commit e4d4011b2819a5466151c76d64b10452a0b51c45
69 changes: 42 additions & 27 deletions server/middleware/session.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,61 @@
/**
* verifyToken.js
* session.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.
* 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 jwt = require('jsonwebtoken')
const config = require('../../config')
const db = require('../database');

function oneHourAgo() {
return (Date.now() / 1000) - (60 * 60);
}

module.exports = (req, res, next) => {
function getRequestToken(req) {
// 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)
throw new Error('No `Authorization` header provided.')
}
return header.replace(/^Bearer /, '')
}

const token = header.replace(/^Bearer /, '')
let account = null
async function middleware(req, res, next) {
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)
}
// 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}`)
}

// Failed: no account found in decoded data
if (!account.data.accountId) {
const err = new Error('JWT is missing account data.')
// 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)
}

// Success: include decoded data in the request
res.locals.accountId = account.data.accountId
res.locals.customerId = account.data.customerId
return next()
}
module.exports = {middleware, getRequestToken}
51 changes: 32 additions & 19 deletions server/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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));
}
});
Expand All @@ -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)
Expand All @@ -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;
24 changes: 12 additions & 12 deletions server/routes/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down