Craig Fletcher 4 years ago
parent
commit
0d154cf1c7
12 changed files with 617 additions and 233 deletions
  1. 1 0
      .env.example
  2. 1 0
      docker-compose.yml
  3. 469 212
      package-lock.json
  4. 9 5
      package.json
  5. 1 1
      src/index.js
  6. 43 0
      src/lib/auth.js
  7. 6 1
      src/modules/db.js
  8. 10 6
      src/routes/goodbye.js
  9. 5 7
      src/routes/hello.js
  10. 3 1
      src/routes/index.js
  11. 36 0
      src/routes/login.js
  12. 33 0
      src/routes/register.js

+ 1 - 0
.env.example

@@ -5,3 +5,4 @@ COUCHDB_HOST=couchdb
 COUCHDB_PORT=5984
 APP_PORT=8080
 APP_HOST=0.0.0.0
+JWT_SECRET=jwt-secret-key

+ 1 - 0
docker-compose.yml

@@ -29,6 +29,7 @@ services:
       - COUCHDB_PORT=${COUCHDB_PORT}
       - APP_PORT=${APP_PORT}
       - APP_HOST=${APP_HOST}
+      - JWT_SECRET=${JWT_SECRET}
 
     volumes:
       - ${PWD}:/app

File diff suppressed because it is too large
+ 469 - 212
package-lock.json


+ 9 - 5
package.json

@@ -13,17 +13,21 @@
   "license": "ISC",
   "dependencies": {
     "app-root-path": "^3.0.0",
+    "bcrypt": "^5.0.1",
     "hasha": "^5.2.2",
-    "nano": "^9.0.3",
+    "jsonwebtoken": "^8.5.1",
+    "nano": "^9.0.5",
     "redis": "^3.1.2",
-    "restify": "^8.5.1",
+    "restify": "^8.6.0",
+    "restify-jwt-community": "^1.1.21",
     "winston": "^3.3.3",
-    "yup": "^0.32.9"
+    "yup": "^0.32.11"
   },
   "devDependencies": {
-    "eslint": "^7.27.0",
+    "eslint": "^7.32.0",
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^8.3.0",
-    "eslint-plugin-import": "^2.23.3"
+    "eslint-plugin-import": "^2.25.3",
+    "prettier": "^2.5.1"
   }
 }

+ 1 - 1
src/index.js

@@ -15,7 +15,7 @@ start({
     protocol: env.COUCHDB_PROTOCOL,
     host: env.COUCHDB_HOST,
     port: env.COUCHDB_PORT,
-    dbs: ['visits'],
+    dbs: ['visits', 'users'],
     username: env.COUCHDB_USER,
     password: env.COUCHDB_PASSWORD,
   },

+ 43 - 0
src/lib/auth.js

@@ -0,0 +1,43 @@
+import jsonwebtoken from 'jsonwebtoken';
+import bcrypt from 'bcrypt';
+import { env } from 'process';
+import jwtMiddlewareFactory from 'restify-jwt-community';
+
+const saltRounds = 10;
+
+export const createUser = async (name, password) => {
+  const hash = await bcrypt.hash(password, saltRounds);
+  const newUser = {
+    name,
+    password: hash,
+    groups: [],
+  };
+  return newUser;
+};
+
+export const getJwt = (user) => {
+  const jwt = {
+    name: user.name,
+    groups: user.groups,
+  };
+  jwt.token = jsonwebtoken.sign(jwt, env.JWT_SECRET);
+  return jwt;
+};
+
+export const checkLogin = async (user, password) => {
+  const correct = await bcrypt.compare(password, user.password);
+  return correct;
+};
+
+export const isInGroup = (user, group) => Boolean(user.groups) && user.groups.includes(group);
+
+export const authMiddleware = jwtMiddlewareFactory({ secret: env.JWT_SECRET });
+
+const authLib = {
+  createUser,
+  getJwt,
+  checkLogin,
+  isInGroup,
+  authMiddleware,
+};
+export default authLib;

+ 6 - 1
src/modules/db.js

@@ -49,7 +49,12 @@ async function dbWrapper(client, dbName) {
 }
 
 async function wrapDbs(client, dbs) {
-  return dbs.reduce(async (wrappedDbs, dbName) => ({ ...wrappedDbs, [dbName]: await dbWrapper(client, dbName) }), {});
+  return dbs.reduce(async (wrappedDbs, dbName) => {
+    // ugly, but guarantees all the dbs are connected before returning.
+    const wrappedDb = await dbWrapper(client, dbName);
+    const others = await wrappedDbs;
+    return { ...others, [dbName]: wrappedDb };
+  }, {});
 }
 
 async function connectAndWrapDbs(opts) {

+ 10 - 6
src/routes/goodbye.js

@@ -1,16 +1,20 @@
-function routeFactory({ dbs, yup, log }) {
+import { authMiddleware, isInGroup } from '../lib/auth.js';
+
+function routeFactory({ dbs, yup, logger }) {
   return {
     verb: 'post',
     path: '/goodbye',
     handlers: [
+      authMiddleware,
       function handler(req, res, next) {
-        const contentPath = ['visits', req.body.name];
-        dbs.visits.get(contentPath, reply => {
+        const contentPath = ['visits', req.user.name];
+        dbs.visits.get(contentPath, (reply) => {
           const visits = reply && reply.visits ? Number(reply.visits) + 1 : 1;
-          log.info(`Visited by ${req.params.name}.`);
-          res.send(`hello ${req.body.name}. You have visited ${visits} times.`);
-          next();
+          logger.info(`Visited by ${req.user.name}.`);
+          const isAdmin = isInGroup(req.user, 'admin');
+          res.json({ name: req.user.name, visits, isAdmin });
           dbs.visits.set(contentPath, { visits });
+          return next();
         });
       },
     ],

+ 5 - 7
src/routes/hello.js

@@ -1,16 +1,14 @@
-function routeFactory({ dbs, yup, log }) {
+function routeFactory({ dbs, yup, logger }) {
   return {
     verb: 'get',
     path: '/hello/:name',
     handlers: [
       function handler(req, res, next) {
         const contentPath = ['visits', req.params.name];
-        dbs.visits.get(contentPath, reply => {
-          const visits = reply && reply.visits ? Number(reply.visits) + 1 : 1;
-          log.info(`Visited by ${req.params.name}.`);
-          res.send(`hello ${req.params.name}. You have visited ${visits} times.`);
-          next();
-          dbs.visits.set(contentPath, { visits });
+        dbs.visits.get(contentPath, (reply) => {
+          logger.info(`Getting visits for ${req.params.name}.`);
+          res.send(`${req.params.name} has visited ${reply.visits} times.`);
+          return next();
         });
       },
     ],

+ 3 - 1
src/routes/index.js

@@ -1,7 +1,9 @@
 import hello from './hello.js';
 import goodbye from './goodbye.js';
+import register from './register.js';
+import login from './login.js';
 
 // re-exporting here for tidiness
-const routeFactories = [hello, goodbye];
+const routeFactories = [hello, goodbye, register, login];
 
 export default routeFactories;

+ 36 - 0
src/routes/login.js

@@ -0,0 +1,36 @@
+import { checkLogin, getJwt } from '../lib/auth.js';
+
+function routeFactory({ dbs, yup, logger }) {
+  return {
+    verb: 'post',
+    path: '/login',
+    handlers: [
+      function handler(req, res, next) {
+        const contentPath = ['users', req.body.name];
+        dbs.users.get(contentPath, async (user) => {
+          if (!user) {
+            logger.info(`User failed to log in: ${req.body.name}`);
+            res.json({ error: 'user or password incorrect' });
+            return res.end();
+          }
+          const passwordCorrect = await checkLogin(user, req.body.password);
+          if (passwordCorrect) {
+            logger.info(`User logged in: ${req.body.name}`);
+            const resp = getJwt(user);
+            res.json(resp);
+            return next();
+          }
+          logger.info(`User failed to log in: ${req.body.name}`);
+          res.json({ error: 'user or password incorrect' });
+          return res.end();
+        });
+      },
+    ],
+    schema: yup.object().shape({
+      name: yup.string().required(),
+      password: yup.string().required(),
+    }),
+  };
+}
+
+export default routeFactory;

+ 33 - 0
src/routes/register.js

@@ -0,0 +1,33 @@
+import { createUser, getJwt } from '../lib/auth.js';
+
+function routeFactory({ dbs, yup, logger }) {
+  return {
+    verb: 'post',
+    path: '/register',
+    handlers: [
+      async function handler(req, res, next) {
+        const contentPath = ['users', req.body.name];
+        dbs.users.get(contentPath, async (reply) => {
+          if (!reply) {
+            const newUser = await createUser(req.body.name, req.body.password);
+            dbs.users.set(contentPath, newUser, () => {
+              logger.info(`Registered new user: ${newUser.name}.`);
+              const resp = getJwt(newUser);
+              res.json(resp);
+              return next();
+            });
+          } else {
+            res.json({ error: 'user exists' });
+            return res.end();
+          }
+        });
+      },
+    ],
+    schema: yup.object().shape({
+      name: yup.string().required(),
+      password: yup.string().required(),
+    }),
+  };
+}
+
+export default routeFactory;

Some files were not shown because too many files changed in this diff