Created
May 20, 2020 08:02
-
-
Save ObjSal/57c3836c1db2cea4cb111c73179d99c5 to your computer and use it in GitHub Desktop.
JWT authentication in Node.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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') | |
} | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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" | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} | |
}) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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