add passport-js auth webhook boilerplate (#528)

This commit is contained in:
Aravind Shankar 2018-09-26 17:57:59 +05:30 committed by Shahidh K Muhammed
parent d0303995e3
commit ebc3589118
12 changed files with 2980 additions and 0 deletions

View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1 @@
web: npm start

View File

@ -0,0 +1,93 @@
# Passport.js Auth Webhook Boilerplate
This is a sample auth webhook for authenticating requests to the Hasura GraphQL Engine. This boilerplate also exposes login and signup endpoints.
Prerequisites
-------------
- PostgreSQL
- Node.js 8.9+
Getting Started
---------------
### Deploy locally
```bash
# Clone the repo
git clone https://github.com/hasura/graphql-engine
# Change directory
cd graphql-engine/community/boilerplates/auth-webhooks/passport-js
# Install NPM dependencies
npm install
# Set DATABASE_URL env
export DATABASE_URL=postgres://<username>:<password>@<host>:<port>/<database_name>
# Apply migrations
# (Note) this step creates a "users" table in the database
knex migrate:latest
# Then simply start your app
npm start
```
### Deploy with Heroku
```bash
# Create heroku app
heroku create <app-name>
# Create PostgreSQL addon
heroku addons:create heroku-postgresql:hobby-dev -a <app-name>
# Add git remote
git remote add heroku https://git.heroku.com/<app-name>.git
# Push changes to heroku
# Note: You need to run this command from the toplevel of the working tree (graphql-enginej)
git subtree push --prefix community/boilerplates/auth-webhooks/passport-js heroku master
# Apply migrations
# (Note) this step creates a "users" table in the database
heroku run knex migrate:latest
```
## Usage
### Signup/Login
Once deployed or started locally, we can create an user using `/signup` API like below:
```bash
curl -H "Content-Type: application/json" \
-d'{"username": "test123", "password": "test123", "confirmPassword": "test123"}' \
http://localhost:8080/signup
```
On success, we get the response:
```json
{
"id": 1,
"username": "test123",
"token": "4ffd5ee92853787836325dcea74c02e4"
}
```
We can also use `/login` API to fetch the user token,
```bash
curl -H "Content-Type: application/json" \
-d'{"username": "test123", "password": "test123"}' \
http://localhost:8080/login
```
### Webhook for GraphQL Engine
Auth webhook that can be configured with Hasura GraphQl Engine is available at `/webhook`. It accepts Authorization header to validate the token against an user.
The client just need to add `Authorization: Bearer <token>` to the request sending to GraphQL Engine.
The endpoint (say `http://localhost:8080/webhook`) can be given as an environment variable `HASURA_GRAPHQL_AUTH_HOOK` to GraphQL Engine.
[Read more about webhook here](https://docs.hasura.io/1.0/graphql/manual/auth/webhook.html).

View File

@ -0,0 +1,50 @@
/**
* Module dependencies.
*/
const express = require('express');
const bodyParser = require('body-parser');
const chalk = require('chalk');
const dotenv = require('dotenv');
const passport = require('passport');
const expressValidator = require('express-validator');
/**
* Load environment variables from .env file, where API keys and passwords are configured.
*/
dotenv.load({ path: '.env.example' });
/**
* Controllers (route handlers).
*/
const userController = require('./controllers/user');
/**
* Create Express server.
*/
const app = express();
/**
* Express configuration.
*/
app.set('host', '0.0.0.0');
app.set('port', process.env.PORT || 8080);
app.set('json spaces', 2); // number of spaces for indentation
app.use(bodyParser.json());
app.use(expressValidator());
app.use(passport.initialize());
app.use(passport.session());
app.post('/login', userController.postLogin);
app.post('/signup', userController.postSignup);
app.get('/webhook', userController.getWebhook);
/**
* Start Express server.
*/
app.listen(app.get('port'), () => {
console.log('%s App is running at http://localhost:%d in %s mode', chalk.green('✓'), app.get('port'), app.get('env'));
console.log(' Press CTRL-C to stop\n');
});
module.exports = app;

View File

@ -0,0 +1,45 @@
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const { Strategy: BearerStrategy } = require('passport-http-bearer');
const { User } = require('../db/schema');
const { errorHandler } = require('../db/errors');
passport.use(
new LocalStrategy({
usernameField: 'username',
passwordField: 'password'
},
function (username, password, done) {
User
.query()
.where('username', username)
.first()
.then(function (user) {
if (!user) { return done('Unknown user'); }
user.verifyPassword(password, function (err, passwordCorrect) {
if (err) { return done(err); }
if (!passwordCorrect) { return done('Invalid password'); }
return done(null, user)
})
}).catch(function (err) {
done(err)
})
}
));
passport.use(new BearerStrategy(
function(token, done) {
User
.query()
.where('token', token)
.first()
.then(function (user){
if (!user) { return done('Invalid Token'); }
return done(null, user);
}).catch(function (err) {
done(err);
});
}
));
module.exports = passport;

View File

@ -0,0 +1,80 @@
const passport = require('../config/passport');
const { User } = require('../db/schema');
const { errorHandler } = require('../db/errors');
const bcrypt = require('bcrypt');
/**
* POST /login
* Sign in using username and password.
*/
exports.postLogin = async (req, res, next) => {
req.assert('username', 'Username is not valid').notEmpty();
req.assert('password', 'Password cannot be blank').notEmpty();
const errors = req.validationErrors();
if (errors) {
return res.status(400).json({'errors': errors});
}
passport.authenticate('local', (err, user, info) => {
if (err) { return handleResponse(res, 400, {'error': err})}
if (user) {
handleResponse(res, 200, user.getUser());
}
})(req, res, next);
};
/**
* POST /signup
* Create a new local account.
*/
exports.postSignup = async (req, res, next) => {
req.assert('username', 'Username is not valid').notEmpty();
req.assert('password', 'Password must be at least 4 characters long').len(4);
req.assert('confirmPassword', 'Passwords do not match').equals(req.body.password);
const errors = req.validationErrors();
if (errors) {
return res.status(400).json({'errors': errors});
}
try {
const user = await User.query()
.allowInsert('[username, password]')
.insert({
username: req.body.username,
password: req.body.password
});
} catch (err) {
errorHandler(err, res);
return;
}
passport.authenticate('local', (err, user, info) => {
if (err) { return handleResponse(res, 400, {'error': err})}
if (user) {
handleResponse(res, 200, user.getUser());
}
})(req, res, next);
};
exports.getWebhook = async (req, res, next) => {
passport.authenticate('bearer', (err, user, info) => {
if (err) { return handleResponse(res, 401, {'error': err}); }
if (user) {
handleResponse(res, 200, {
'X-Hasura-Role': 'user',
'X-Hasura-User-Id': `${user.id}`
});
} else {
handleResponse(res, 200, {'X-Hasura-Role': 'anonymous'});
}
})(req, res, next);
}
function handleResponse(res, code, statusMsg) {
res.status(code).json(statusMsg);
}

View File

@ -0,0 +1,120 @@
const {
ValidationError,
NotFoundError
} = require('objection');
const {
DBError,
ConstraintViolationError,
UniqueViolationError,
NotNullViolationError,
ForeignKeyViolationError,
CheckViolationError,
DataError
} = require('objection-db-errors');
// In this example `res` is an express response object.
function errorHandler(err, res) {
if (err instanceof ValidationError) {
switch (err.type) {
case 'ModelValidation':
res.status(400).send({
message: err.message,
type: 'ModelValidation',
data: err.data
});
break;
case 'RelationExpression':
res.status(400).send({
message: err.message,
type: 'InvalidRelationExpression',
data: {}
});
break;
case 'UnallowedRelation':
res.status(400).send({
message: err.message,
type: 'UnallowedRelation',
data: {}
});
break;
case 'InvalidGraph':
res.status(400).send({
message: err.message,
type: 'InvalidGraph',
data: {}
});
break;
default:
res.status(400).send({
message: err.message,
type: 'UnknownValidationError',
data: {}
});
break;
}
} else if (err instanceof NotFoundError) {
res.status(404).send({
message: err.message,
type: 'NotFound',
data: {}
});
} else if (err instanceof UniqueViolationError) {
res.status(409).send({
message: err.message,
type: 'UniqueViolation',
data: {
columns: err.columns,
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof NotNullViolationError) {
res.status(400).send({
message: err.message,
type: 'NotNullViolation',
data: {
column: err.column,
table: err.table,
}
});
} else if (err instanceof ForeignKeyViolationError) {
res.status(409).send({
message: err.message,
type: 'ForeignKeyViolation',
data: {
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof CheckViolationError) {
res.status(400).send({
message: err.message,
type: 'CheckViolation',
data: {
table: err.table,
constraint: err.constraint
}
});
} else if (err instanceof DataError) {
res.status(400).send({
message: err.message,
type: 'InvalidData',
data: {}
});
} else if (err instanceof DBError) {
res.status(500).send({
message: err.message,
type: 'UnknownDatabaseError',
data: {}
});
} else {
res.status(500).send({
message: err.message,
type: 'UnknownError',
data: {}
});
}
}
module.exports = { errorHandler }

View File

@ -0,0 +1,15 @@
exports.up = function(knex, Promise) {
return knex.schema.createTable('users', (table) => {
table.increments();
table.string('username').unique().notNullable();
table.string('password').notNullable();
table.string('token').notNullable();
table.timestamp('created_at').notNullable().defaultTo(knex.raw('now()'));
});
};
exports.down = function(knex, Promise) {
return knex.schema.dropTable('users');
};

View File

@ -0,0 +1,54 @@
const { promisify } = require('util');
const Knex = require('knex');
const connection = require('../knexfile');
const { Model } = require('objection');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const knexConnection = Knex(connection);
const randomBytesAsync = promisify(crypto.randomBytes);
Model.knex(knexConnection);
class User extends Model {
static get tableName () {
return 'users'
}
static get idColumn() {
return 'id';
}
getUser() {
return {
'id': this.id,
'username': this.username,
'token': this.token
}
}
async $beforeInsert () {
const salt = bcrypt.genSaltSync();
this.password = await bcrypt.hash(this.password, salt)
const createRandomToken = await randomBytesAsync(16).then(buf => buf.toString('hex'));
this.token = createRandomToken
}
verifyPassword (password, callback) {
bcrypt.compare(password, this.password, callback)
};
static get jsonSchema () {
return {
type: 'object',
required: ['username'],
properties: {
id: {type: 'integer'},
username: {type: 'string', minLength: 1, maxLength: 255},
token: {type: 'string', minLength: 1, maxLength: 255}
}
}
}
}
module.exports = { User }

View File

@ -0,0 +1,14 @@
// Update with your config settings.
const databaseName = "postgres";
const pg = require('pg');
const connection_url = process.env.DATABASE_URL || `postgres://postgres:@localhost:5432/${databaseName}`;
module.exports = {
client: 'pg',
connection: connection_url,
migrations: {
directory: __dirname + '/db/migrations'
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
{
"name": "passport-js-auth-webhook",
"version": "1.0.0",
"engines": {
"node": "8.9.1"
},
"description": "A boilerplate for Hasura Graphql Engine auth webhook with passport js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node app.js",
"lint": "eslint \"**/*.js\""
},
"repository": {
"type": "git",
"url": "git+https://github.com/hasura/graphql-engine.git"
},
"author": "Aravind Shankar",
"license": "MIT",
"bugs": {
"url": "https://github.com/hasura/graphql-engine/issues"
},
"homepage": "https://github.com/hasura/graphql-engine/tree/master/community/boilerplates/auth-webhooks/passport-js#readme",
"dependencies": {
"bcrypt": "^3.0.0",
"body-parser": "^1.18.3",
"chalk": "^2.4.1",
"crypto": "^1.0.1",
"dotenv": "^6.0.0",
"express": "^4.16.3",
"express-validator": "^5.3.0",
"knex": "^0.15.2",
"objection": "^1.2.6",
"objection-db-errors": "^1.0.0",
"passport": "^0.4.0",
"passport-http-bearer": "^1.0.1",
"passport-local": "^1.0.0",
"pg": "^7.4.3",
"util": "^0.11.0"
}
}