Skip to content

Instantly share code, notes, and snippets.

@ObjSal
Created May 20, 2020 08:02
Show Gist options
  • Save ObjSal/57c3836c1db2cea4cb111c73179d99c5 to your computer and use it in GitHub Desktop.
Save ObjSal/57c3836c1db2cea4cb111c73179d99c5 to your computer and use it in GitHub Desktop.
JWT authentication in Node.js
// Author: Salvador Guerrero
'use strict'
const fs = require('fs')
const crypto = require('crypto')
// Third-Party Modules
const {MongoClient, ObjectId} = require('mongodb')
const jwt = require('jsonwebtoken')
// Project modules
const { CreateServer } = require('./server')
const SecurityUtils = require('./security-utils')
function getSecret(key) {
const filename = 'secrets.json'
let content
try {
if (fs.existsSync(filename)) {
content = fs.readFileSync(filename, 'utf8')
content = JSON.parse(content)
const secret = content[key]
if (secret) {
return secret
}
}
// File or key doesn't exist, create new secret and save.
if (!content) content = {}
const newSecretBuffer = crypto.randomBytes(32)
content[key] = newSecretBuffer.toString('base64')
fs.writeFileSync(filename, JSON.stringify(content, null, 2))
return content[key]
} catch (err) {
console.error(err)
// Secrets are an important asset for apps, if they fail continue throwing
// maybe crash the app so developer catch this early.
throw new Error(`Could not get secret!`)
}
}
function getJWTPayload(request) {
let authorization = request.headers['authorization']
if (!authorization) {
return null
}
authorization = SecurityUtils.getMatching(authorization, /(?:Bearer )(.+)/)
if (!authorization) {
return null
}
try {
return jwt.verify(authorization, jwt_secret)
} catch (err) {
console.error(err)
return null
}
}
// WARNING: Keep these secrets safe!
// If these secrets get lost there's no way to validate passwords or JWT tokens
// store in a secure external drive if possible, outside the server where intruders
// don't have access.
const jwt_secret = getSecret('jwt')
const hmac_secret = getSecret('hmac')
function endRequestWithMessage(response, body, statusCode, message) {
response.statusCode = statusCode
if (message) {
response.setHeader('Content-Type', 'application/json')
if (message instanceof Object) {
body.end(JSON.stringify(message))
} else {
body.end(JSON.stringify({message: message}))
}
} else {
body.end()
}
}
// MongoDB
const mongoClient = new MongoClient('mongodb://localhost:27017', { useUnifiedTopology: true })
async function getDbInstance(name) {
if (!mongoClient.isConnected()) {
await mongoClient.connect()
}
return mongoClient.db(name)
}
function createSecureHash(data, salt) {
const saltLen = 32
const iterations = 100000
const digestAlg = 'sha256'
if (!salt) {
// Create a random salt
// As a rule of thumb, make your salt is at least as long as the hash function's output.
// The US National Institute of Standards and Technology recommends a salt length of 128 bits.
// Ref: https://crackstation.net/hashing-security.htm
// Ref: https://en.wikipedia.org/wiki/PBKDF2
salt = crypto.randomBytes(saltLen)
}
// LastPass in 2011 used 5000 iterations for JavaScript clients and 100000 iterations for
// server-side hashing.
// Ref: https://en.wikipedia.org/wiki/PBKDF2
const derivedKey = crypto.pbkdf2Sync(data, salt, iterations, saltLen, digestAlg)
// Make an impossible to hack keyed hash with HMAC
const hmac = crypto.createHmac(digestAlg, hmac_secret)
hmac.update(derivedKey)
return { hash: hmac.digest('base64'), salt: salt.toString('base64') }
}
CreateServer((request, response, body) => {
if (request.url === '/' && request.method === 'GET') {
response.setHeader('Content-Type', 'text/html')
const stream = fs.createReadStream(`${__dirname}/index.html`)
stream.pipe(body)
} else if (request.url === '/create' && request.method === 'POST') {
// When creating an account, I'm expecting to also receive an image
// that's why I'm limiting the content-length to 5 MB
const maxContentLength = 1024 /*1KB*/ * 1024 /*1MB*/ * 5 /*MB*/
SecurityUtils.readRequestDataInMemory(request, response, body, maxContentLength, (error, data) => {
if (error) {
endRequestWithMessage(response, body, error.statusCode, error.message)
return
}
(async () => {
try {
const db = await getDbInstance('test')
let users = db.collection('users')
let result = await users.findOne({username: data.username})
if (result) {
endRequestWithMessage(response, body, 401, 'Username already exists, pick another one')
return
}
const hashNSalt = createSecureHash(data.password)
result = await users.insertOne({
username: data.username,
password: hashNSalt.hash,
salt: hashNSalt.salt,
creationDate: Date.now()
})
if (result.insertedCount > 0) {
endRequestWithMessage(response, body, 200, 'User created successfully')
} else {
endRequestWithMessage(response, body, 200, 'User could not be created')
}
} catch (dbError) {
console.error(dbError)
endRequestWithMessage(response, body, 500, 'Error creating the account, please try again.')
}
})()
})
} else if (request.url === '/signIn' && request.method === 'POST') {
const maxContentLength = 500
SecurityUtils.readRequestDataInMemory(request, response, body, maxContentLength, (error, data) => {
if (error) {
endRequestWithMessage(response, body, error.statusCode, error.message)
return
}
(async () => {
try {
const db = await getDbInstance('test')
const users = db.collection('users')
const result = await users.findOne({username: data.username})
const hashNSalt = createSecureHash(data.password, Buffer.from(result.salt, 'base64'))
if (hashNSalt.hash === result.password) {
// JWT timestamps are in seconds.
const iat = Math.floor(Date.now() / 1000)
let payload = {
iat: iat,
exp: iat + (60 /*1MIN*/ * 60 /*1HR*/),
id: result._id
}
const token = jwt.sign(payload, jwt_secret)
endRequestWithMessage(response, body, 200, {token: token})
} else {
endRequestWithMessage(response, body, 500, 'Sign in failed, try again.')
}
} catch (dbError) {
console.error(dbError)
endRequestWithMessage(response, body, 500, 'Error signing in, please try again.')
}
})()
})
} else if (request.url === '/update' && request.method === 'POST') {
let jwtPayload = getJWTPayload(request)
if (!jwtPayload) {
endRequestWithMessage(response, body, 401, 'Not Authorized')
return
}
const maxContentLength = 500
SecurityUtils.readRequestDataInMemory(request, response, body, maxContentLength, (error, data) => {
if (error) {
endRequestWithMessage(response, body, error.statusCode, error.message)
return
}
(async () => {
try {
const db = await getDbInstance('test')
const users = db.collection('users')
let result = await users.updateOne(
{ _id: ObjectId(jwtPayload.id) },
{ $set: { username: data.username } }
)
if (result.modifiedCount > 0) {
endRequestWithMessage(response, body, 200, 'User updated successfully')
} else {
endRequestWithMessage(response, body, 200, 'User could not get updated')
}
} catch (dbError) {
console.error(dbError)
endRequestWithMessage(response, body, 500, 'Error signing in, please try again.')
}
})()
})
} else {
endRequestWithMessage(response, body, 404, '>Page Doesn\'t exist')
}
})
// Author: Salvador Guerrero
'use strict'
// https://nodejs.org/api/zlib.html
const zlib = require('zlib')
const kGzip = 'gzip'
const kDeflate = 'deflate'
const kBr = 'br'
const kAny = '*'
const kIdentity = 'identity'
class EncoderInfo {
constructor(name) {
this.name = name
}
isIdentity() {
return this.name === kIdentity
}
createEncoder() {
switch (this.name) {
case kGzip: return zlib.createGzip()
case kDeflate: return zlib.createDeflate()
case kBr: return zlib.createBrotliCompress()
default: return null
}
}
}
class ClientEncodingInfo {
constructor(name, qvalue) {
this.name = name
this.qvalue = qvalue
}
}
exports.getSupportedEncoderInfo = function getSupportedEncoderInfo(request) {
// See https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
let acceptEncoding = request.headers['accept-encoding']
let acceptEncodings = []
let knownEncodings = [kGzip, kDeflate, kBr, kAny, kIdentity]
// If explicit is true, then it means the client sent *;q=0, meaning accept only given encodings
let explicit = false
if (!acceptEncoding || acceptEncoding.trim().length === 0) {
// If the Accept-Encoding field-value is empty, then only the "identity" encoding is acceptable.
knownEncodings = [kIdentity]
acceptEncodings = [new ClientEncodingInfo(kIdentity, 1)]
} else {
// NOTE: Only return 406 if the client sends 'identity;q=0' or a '*;q=0'
let acceptEncodingArray = acceptEncoding.split(',')
for (let encoding of acceptEncodingArray) {
encoding = encoding.trim()
if (/[a-z*];q=0$/.test(encoding)) {
// The "identity" content-coding is always acceptable, unless
// specifically refused because the Accept-Encoding field includes
// "identity;q=0", or because the field includes "*;q=0" and does
// not explicitly include the "identity" content-coding.
let split = encoding.split(';')
let name = split[0].trim()
if (name === kAny) {
explicit = true
}
knownEncodings.splice(knownEncodings.indexOf(name), 1)
} else if (/[a-z*]+;q=\d+(.\d+)*/.test(encoding)) {
// This string contains a qvalue.
let split = encoding.split(';')
let name = split[0].trim()
let value = split[1].trim()
value = value.split('=')[1]
value = parseFloat(value)
acceptEncodings.push(new ClientEncodingInfo(name, value))
} else {
// No qvalue, treat it as q=1.0
acceptEncodings.push(new ClientEncodingInfo(encoding.trim(), 1.0))
}
}
// order by qvalue, max to min
acceptEncodings.sort((a, b) => {
return b.qvalue - a.qvalue
})
}
// `acceptEncodings` is sorted by priority
// Pick the first known encoding.
let encoding = ''
for (let encodingInfo of acceptEncodings) {
if (knownEncodings.indexOf(encodingInfo.name) !== -1) {
encoding = encodingInfo.name
break
}
}
// If any, pick a known encoding
if (encoding === kAny) {
for (let knownEncoding of knownEncodings) {
if (knownEncoding === kAny) {
continue
} else {
encoding = knownEncoding
break
}
}
}
// If no known encoding was set, then use identity if not excluded
if (encoding.length === 0) {
if (!explicit && knownEncodings.indexOf(kIdentity) !== -1) {
encoding = kIdentity
} else {
console.error('No known encoding were found in accept-encoding, return http status code 406')
return null
}
}
return new EncoderInfo(encoding)
}
<html lang="en">
<head>
<title>Home</title>
<script>
'use strict'
function arrayBufferToBase64(buffer) {
return btoa(new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), ''))
}
async function securePasswordHash(password) {
if (!password) return null
// (generates a random salt) let saltBuffer = window.crypto.getRandomValues(new Uint8Array(32))
let textEncoder = new TextEncoder()
// Use the reversed password and use it as the salt
let saltBuffer = textEncoder.encode(password.split('').reverse().join(''))
let encodedPassword = textEncoder.encode(password)
let baseKey = await window.crypto.subtle.importKey(
"raw",
encodedPassword,
"PBKDF2",
false,
["deriveBits"]
)
// LastPass in 2011 used 5000 iterations for JavaScript clients and 100000 iterations for
// server-side hashing.
// Ref: https://en.wikipedia.org/wiki/PBKDF2
let keyBuffer = await window.crypto.subtle.deriveBits(
{
"name": "PBKDF2",
"hash": "SHA-256",
salt: saltBuffer,
"iterations": 5000
},
baseKey,
256
)
return arrayBufferToBase64(keyBuffer)
}
function onSignIn(form) {
(async()=> {
// Bind the FormData object and the form element
// FormData will always send multipart/form-data
const formData = new FormData(form)
// Replace password with the secure password hash
formData.set("password", await securePasswordHash(formData.get("password")))
try {
const response = await fetch('/signIn', {
method: 'POST',
body: formData
})
const text = await response.text()
if (response.status !== 200) {
if (text) {
console.error(text)
} else {
console.error('There was an error without description')
}
} else {
try {
const token = JSON.parse(text)
localStorage.setItem('token', token.token)
} catch (err) {
console.error(err)
}
}
if (text) document.body.innerHTML = text
} catch (e) {
console.error(e.message)
}
})()
}
function onCreateAccount(form) {
(async()=> {
// Bind the FormData object and the form element
// FormData will always send multipart/form-data
const formData = new FormData(form)
// Replace password with the secure password hash
formData.set("password", await securePasswordHash(formData.get("password")))
try {
const response = await fetch('/create', {
method: 'POST',
body: formData
})
const text = await response.text()
if (response.status !== 200) {
if (text) {
console.error(text)
} else {
console.error('There was an error without description')
}
// return
}
if (text) {
document.body.innerHTML = text
}
} catch (e) {
console.error(e.message)
}
})()
}
function onChangeAccount(form) {
(async()=> {
let token = localStorage.getItem('token')
if (!token) {
alert('Did you forget to Sign In?')
return
}
// Bind the FormData object and the form element
// FormData will always send multipart/form-data
const formData = new FormData(form)
try {
const response = await fetch('/update', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
})
const text = await response.text()
if (response.status !== 200) {
if (text) {
console.error(text)
} else {
console.error('There was an error without description')
}
}
if (text) document.body.innerHTML = text
} catch (e) {
console.error(e.message)
}
})()
}
</script>
</head>
<body>
<h1>Sign In</h1>
<form action="javascript:" onsubmit="onSignIn(this)">
<input id="signInUsername" type="text" name="username" placeholder="username" value="sal" required><br />
<input id="signInPassword" type="password" name="password" placeholder="password" value="myWeakPass" required><br />
<input type="submit">
</form>
<hr />
<h1>Create Account</h1>
<form action="javascript:" onsubmit="onCreateAccount(this)">
<input id="createUsername" type="text" name="username" placeholder="username" value="sal" required><br />
<input id="createPassword" type="password" name="password" placeholder="password" value="myWeakPass" required><br />
<input id="createPicture" type="file" name="picture"><br />
<input type="submit">
</form>
<hr />
<h1>Change Username</h1>
<form action="javascript:" onsubmit="onChangeAccount(this)">
<input id="changeUsername" type="text" name="username" placeholder="username" value="sal" required><br />
<input type="submit">
</form>
</body>
</html>
{
"name": "proj04-mongo",
"version": "1.0.0",
"description": "",
"main": "app.js",
"dependencies": {
"jsonwebtoken": "~8.5.1",
"mongodb": "~3.5.7"
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Salvador Guerrero"
}
// Author: Salvador Guerrero
'use strict'
const querystring = require('querystring')
const kApplicationJSON = 'application/json'
const kApplicationFormUrlEncoded = 'application/x-www-form-urlencoded'
const kMultipartFormData = 'multipart/form-data'
function getMatching(string, regex) {
// Helper function when using non-matching groups
const matches = string.match(regex)
if (!matches || matches.length < 2) {
return null
}
return matches[1]
}
exports.getMatching = getMatching
function getBoundary(contentTypeArray) {
const boundaryPrefix = 'boundary='
let boundary = contentTypeArray.find(item => item.startsWith(boundaryPrefix))
if (!boundary) return null
boundary = boundary.slice(boundaryPrefix.length)
if (boundary) boundary = boundary.trim()
return boundary
}
class RequestError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
exports.readRequestDataInMemory = (request, response, body, maxLength, callback) => {
const contentLength = parseInt(request.headers['content-length'])
if (isNaN(contentLength)) {
callback(new RequestError('Length required', 411))
return
}
// Don't need to validate while reading, V8 runtime only reads what content-length specifies.
if (contentLength > maxLength) {
callback(new RequestError(`Content length is greater than ${maxLength} Bytes`, 413))
return
}
let contentType = request.headers['content-type']
const contentTypeArray = contentType.split(';').map(item => item.trim())
if (contentTypeArray && contentTypeArray.length) {
contentType = contentTypeArray[0]
}
if (!contentType) {
callback(new RequestError('Content type not specified', 400))
return
}
if (!/((application\/(json|x-www-form-urlencoded))|multipart\/form-data)/.test(contentType)) {
callback(new RequestError('Content type not specified', 400))
return
}
if (contentType === kMultipartFormData) {
// Use latin1 encoding to parse binary files correctly
request.setEncoding('latin1')
} else {
request.setEncoding('utf8')
}
let rawData = ''
request.on('data', chunk => {
rawData += chunk
})
request.on('end', () => {
switch (contentType) {
case kApplicationJSON: {
try {
callback(null, JSON.parse(rawData))
} catch (e) {
console.error(e)
callback(new RequestError('There was an error trying to parse the data as JSON', 400))
}
break
}
case kApplicationFormUrlEncoded: {
try {
let parsedData = querystring.decode(rawData)
callback(null, parsedData)
} catch (e) {
console.error(e)
callback(new RequestError('There was an error trying to parse the form data', 400))
}
break
}
case kMultipartFormData: {
const boundary = getBoundary(contentTypeArray)
if (!boundary) {
callback(new RequestError('Boundary information missing', 400))
return
}
let result = {}
const rawDataArray = rawData.split(boundary)
for (let item of rawDataArray) {
// Use non-matching groups to exclude part of the result
let name = getMatching(item, /(?:name=")(.+?)(?:")/)
if (!name || !(name = name.trim())) continue
let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/)
if (!value) continue
let filename = getMatching(item, /(?:filename=")(.*?)(?:")/)
if (filename && (filename = filename.trim())) {
// Add the file information in a files array
let file = {}
file[name] = value
file['filename'] = filename
let contentType = getMatching(item, /(?:Content-Type:)(.*?)(?:\r\n)/)
if (contentType && (contentType = contentType.trim())) {
file['Content-Type'] = contentType
}
if (!result.files) {
result.files = []
}
result.files.push(file)
} else {
// Key/Value pair
result[name] = value
}
}
callback(null, result)
break
}
default: {
callback(null, rawData)
}
}
})
}
// Author: Salvador Guerrero
'use strict'
const fs = require('fs')
const http = require('http')
const { pipeline, PassThrough } = require('stream')
// Project modules
const { getSupportedEncoderInfo } = require('./encoding-util')
exports.CreateServer = function CreateServer(callback) {
http.createServer((request, response) => {
let encoderInfo = getSupportedEncoderInfo(request)
if (!encoderInfo) {
// Encoded not supported by this server
response.statusCode = 406
response.setHeader('Content-Type', 'application/json')
response.end(JSON.stringify({error: 'Encodings not supported'}))
return
}
let body = response
response.setHeader('Content-Encoding', encoderInfo.name)
// If encoding is not identity, encode the response =)
if (!encoderInfo.isIdentity()) {
const onError = (err) => {
if (err) {
// If an error occurs, there's not much we can do because
// the server has already sent the 200 response code and
// some amount of data has already been sent to the client.
// The best we can do is terminate the response immediately
// and log the error.
response.end()
console.error('An error occurred:', err)
}
}
body = new PassThrough()
pipeline(body, encoderInfo.createEncoder(), response, onError)
}
if (request.url === '/favicon.ico' && request.method === 'GET') {
const path = `${__dirname}/rambo.ico`
const contentType = 'image/vnd.microsoft.icon'
// Chrome & Safari have issues caching favicon's
response.setHeader('Content-Type', contentType)
fs.createReadStream(path).pipe(body)
} else {
callback(request, response, body)
}
}).listen(3000, () => {
console.log(`Server running at http://localhost:3000/`);
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment