Skip to content

Commit

Permalink
Merge pull request #2 from jeff-pedro/feature-week3
Browse files Browse the repository at this point in the history
Week 3 release
  • Loading branch information
jeff-pedro authored Dec 15, 2022
2 parents 8b0703c + 736dfcd commit 597c770
Show file tree
Hide file tree
Showing 33 changed files with 3,097 additions and 175 deletions.
1,298 changes: 1,227 additions & 71 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "set NODE_ENV=test&& mocha --recursive ./test/**/*.test.js --config test/integration/mocharc.json --exit"
"test": "set NODE_ENV=test&& mocha ./test/src/**/*.test.js --config test/mocharc.json",
"test:integration": "set NODE_ENV=test&& mocha ./test/**/*.test.js --config test/mocharc.json",
"lint": "eslint --ext .js,.ts ./src"
},
"repository": {
"type": "git",
Expand All @@ -17,11 +19,21 @@
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.1.0",
"body-parser": "^1.20.1",
"dotenv": "^16.0.1",
"express": "^4.18.1",
"express-validator": "^6.14.2",
"jsonwebtoken": "^8.5.1",
"moment": "^2.29.4",
"mongoose": "^6.5.0",
"morgan": "^1.10.0"
"morgan": "^1.10.0",
"nodemailer": "^6.8.0",
"passport": "^0.6.0",
"passport-http-bearer": "^1.0.1",
"passport-local": "^1.0.0",
"passport-local-mongoose": "^7.1.2",
"redis": "3.0.2"
},
"devDependencies": {
"chai": "^4.3.6",
Expand Down
6 changes: 6 additions & 0 deletions redis/allowlistRefreshToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createClient } from 'redis';
import listHandler from './listHandler.js';

const allowlist = createClient({ prefix: 'allowlist-refresh-token: ' });

export default listHandler(allowlist);
25 changes: 25 additions & 0 deletions redis/blocklistAccessToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { createClient } from 'redis';
import listHandler from './listHandler.js';

const blocklist = createClient({ prefix: 'blocklist-access-token: ' });
const blocklistHandler = listHandler(blocklist);

function generateTokenHash(token) {
return createHash('sha256')
.update(token)
.digest('hex');
}

export default ({
add: async (token) => {
const expirationDate = jwt.decode(token).exp;
const tokenHash = generateTokenHash(token);
await blocklistHandler.add(tokenHash, '', expirationDate);
},
tokenExists: async (token) => {
const tokenHash = generateTokenHash(token);
return blocklistHandler.containKey(tokenHash);
},
});
25 changes: 25 additions & 0 deletions redis/listHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { promisify } from 'util';

export default (list) => {
const setAsync = promisify(list.set).bind(list);
const existsAsync = promisify(list.exists).bind(list);
const getAsync = promisify(list.get).bind(list);
const delAsync = promisify(list.del).bind(list);

return {
async add(key, value, expirationDate) {
await setAsync(key, value);
list.expireat(key, expirationDate);
},
async containKey(key) {
const result = await existsAsync(key);
return result === 1;
},
async findValue(key) {
return getAsync(key);
},
async delete(key) {
delAsync(key);
},
};
};
10 changes: 9 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import express from 'express';
import logger from 'morgan';
import bodyParser from 'body-parser';
import db from './config/db.js';
import routes from './routes/index.js';

// passport strategies to authenticate
import './auth/strategies.js';

const app = express();

// database's connection
db.on('error', console.error.bind(console, 'connection error:'));

// use morgan to log at command line
// use morgan to log requests
app.use(logger('combined', {
// don't show the log when it is test
skip: (req, res) => process.env.NODE_ENV === 'test',
}));

// use body-parser to transform json to object
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

routes(app);

export default app;
46 changes: 46 additions & 0 deletions src/auth/emails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import nodemailer from 'nodemailer';

const emailConfig = {
host: process.env.EMAIL_HOST,
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
secure: true,
};

const emailConfigTest = (testAccount) => ({
host: 'smtp.ethereal.email',
auth: testAccount,
});

async function createEmailConfig() {
if (process.env.NODE_ENV === 'prod') {
return emailConfig;
}
const testAccount = await nodemailer.createTestAccount();
return emailConfigTest(testAccount);
}

class Email {
async sendEmail() {
const config = await createEmailConfig();
const transporter = nodemailer.createTransport(config);
const info = await transporter.sendMail(this);

if (process.env.NODE_ENV !== 'prod') {
console.log(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
}
}
}

export default class CheckEmail extends Email {
constructor(user, url) {
super();
this.from = '"Personal Finance API 👻" <no-reply@example.com>';
this.to = user.email;
this.subject = 'Email checking ✔';
this.text = `Hello! Check your email here: ${url}`;
this.html = `<h1>Hello!</h1> Check your email here: <a href="${url}">${url}</a>`;
}
}
94 changes: 94 additions & 0 deletions src/auth/middlewareAuth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import passport from 'passport';
import Users from '../model/User.js';
import tokens from './tokens.js';

export default {
local: (req, res, next) => {
passport.authenticate(
'local',
{ session: false },
(_, user, err) => {
if (err && err.message === 'Missing credentials') {
return res.status(401).json({ error: err.message });
}

if (err && err.message === 'Password or username is incorrect') {
return res.status(401).json({ error: err.message });
}

if (err) {
return res.status(500).json({ error: err.message });
}

if (!user) {
return res.status(401).json();
}

req.user = user;
return next();
},
)(req, res, next);
},
bearer: (req, res, next) => {
passport.authenticate(
'bearer',
{ session: false },
(err, user, info) => {
if (err && err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: err.message });
}

if (err && err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'token expired', expiredAt: err.expiredAt });
}

// if (err && err.message === ) {
// return res.status(500).json({ error: err.message });
// }

if (err) {
return res.status(500).json({ error: err.message });
}

if (!user) {
return res.status(401).json();
}

req.token = info.token;
req.user = user;
return next();
},
)(req, res, next);
},
refresh: async (req, res, next) => {
try {
const { refreshToken } = req.body;
const id = await tokens.refresh.verify(refreshToken);
await tokens.refresh.invalidate(refreshToken);
const user = await Users.findById(id);
req.user = user;
return next();
} catch (err) {
if (err.message === 'refresh token invalid') {
return res.status(401).json({ error: err.message });
}

if (err.message === 'no refresh token provided') {
return res.status(401).json({ error: err.message });
}

res.status(500).json({ error: err.message });
}
},
emailChecking: async (req, res, next) => {
try {
const { token } = req.params;
const id = await tokens.emailChecking.verify(token);
const user = await Users.findById(id);
req.user = user;
next();
} catch (err) {
res.status(500).json({ error: err.message });
}
},
};
23 changes: 23 additions & 0 deletions src/auth/strategies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as BearerStrategy } from 'passport-http-bearer';
import passport from 'passport';
import Users from '../model/User.js';
import tokens from './tokens.js';

passport.use(
new LocalStrategy({ session: false }, Users.authenticate()),
);

passport.use(
new BearerStrategy(
async (token, done) => {
try {
const id = await tokens.access.verify(token);
const user = await Users.findById(id);
done(null, user, { token });
} catch (err) {
done(err);
}
},
),
);
103 changes: 103 additions & 0 deletions src/auth/tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import jwt from 'jsonwebtoken';
import moment from 'moment';
import { randomBytes } from 'crypto';
import blocklistAccessToken from '../../redis/blocklistAccessToken.js';
import allowlistRefreshToken from '../../redis/allowlistRefreshToken.js';

async function checkTokenInBlocklist(token, name, blocklist) {
if (!blocklist) {
return;
}

const tokenInBlocklist = await blocklist.tokenExists(token);
if (tokenInBlocklist) {
throw new jwt.JsonWebTokenError(`invalid ${name} by logout`);
}
}

function checkUser(id, name) {
if (!id) {
throw new Error(`${name} invalid`);
}
}

function checkToken(token, name) {
if (!token) {
throw new Error(`no ${name} provided`);
}
}

function createTokenJWT(id, [expAmount, expUnit]) {
const payload = { id };
const token = jwt.sign(payload, process.env.JWT_KEY, { expiresIn: expAmount + expUnit });
return token;
}

async function verifyTokenJWT(token, name, blocklist) {
await checkTokenInBlocklist(token, name, blocklist);
const { id } = jwt.verify(token, process.env.JWT_KEY);
return id;
}

async function invalidateTokenJWT(token, blocklist) {
await blocklist.add(token);
}

async function createOpaqueToken(id, [expAmount, expUnit], allowlist) {
const opaqueToken = randomBytes(24).toString('hex');
const expirationDate = moment().add(expAmount, expUnit).unix();
await allowlist.add(opaqueToken, id, expirationDate);
return opaqueToken;
}

async function verifyOpaqueToken(token, name, allowlist) {
checkToken(token, name);
const id = await allowlist.findValue(token);
checkUser(id, name);
return id;
}

async function invalidateOpaqueToken(token, allowlist) {
await allowlist.delete(token);
}

export default {
access: {
name: 'access token',
list: blocklistAccessToken,
expiration: [15, 'm'],
create(id) {
return createTokenJWT(id, this.expiration);
},
async verify(token) {
return verifyTokenJWT(token, this.name, this.list);
},
async invalidate(token) {
return invalidateTokenJWT(token, this.list);
},
},
refresh: {
name: 'refresh token',
list: allowlistRefreshToken,
expiration: [5, 'd'],
create(id) {
return createOpaqueToken(id, this.expiration, this.list);
},
verify(token) {
return verifyOpaqueToken(token, this.name, this.list);
},
invalidate(token) {
return invalidateOpaqueToken(token, this.list);
},
},
emailChecking: {
name: 'email checking token',
expiration: [1, 'h'],
create(id) {
return createTokenJWT(id, this.expiration);
},
verify(token) {
return verifyTokenJWT(token, this.name);
},
},
};
Loading

0 comments on commit 597c770

Please sign in to comment.