Commit 5384832f authored by Juha's avatar Juha

Initial implementation

parent e9aa0182
......@@ -2,7 +2,7 @@
"extends": "airbnb",
"rules": {
"func-names": ["error", "never"],
"no-console": ["error", { allow: ["log", "warn", "error"] }],
"no-console": ["error", { "allow": ["log", "warn", "error"] }],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
......
......@@ -7,7 +7,7 @@
/shippable
# local config
/config/development.json
/config/nconf/*.json
# misc
.DS_Store
......
const path = require('path');
module.exports = {
'config': path.resolve('config', 'sequelize', 'config.json'),
'models-path': path.resolve('models', 'sequelize'),
'seeders-path': path.resolve('db', 'sequelize', 'seeders'),
'migrations-path': path.resolve('db', 'sequelize', 'migrations')
}
FROM node:6.10.2-alpine
FROM node:8.11.2-alpine
# Create app directory
RUN mkdir /src
......@@ -6,6 +6,9 @@ RUN mkdir /src
# Install app dependencies
ADD package.json npm-shrinkwrap.json /src/
# Install native apk dependencies for bcryptjs node module
RUN apk --no-cache add --virtual native-deps g++ gcc libgcc libstdc++ linux-headers make python
# Install app dependencies
RUN cd /src && npm install --loglevel warn
......
FROM node:6.10.2-alpine
FROM node:8.11.2-alpine
# Create app directory
RUN mkdir /src
......@@ -6,19 +6,24 @@ RUN mkdir /src
# Install app dependencies
ADD package.json npm-shrinkwrap.json /src/
# Update npm to a new version in a slightly complicated way to go around several issues
# More info: https://github.com/npm/npm/issues/15611#issuecomment-289133810
RUN cd ~ && npm install npm@4.5.0 && rm -rf /usr/local/lib/node_modules && mv node_modules /usr/local/lib/
# Update npm to a spesific version
RUN npm install npm@5.6.0 -g
# Install native apk dependencies for bcryptjs node module
RUN apk --no-cache add --virtual native-deps g++ gcc libgcc libstdc++ linux-headers make python
# Change the ownership of the copied files and change to node user
RUN chown -R node:node /src
USER node
# Install production dependencies
RUN cd /src && npm install --only=production --no-optional --loglevel warn && npm cache clean
RUN cd /src && npm install --only=production --no-optional --loglevel warn && npm cache clean --force
# Copy all the files into the container to make the container independent from local files
# Remove the installed native dependencies to reduce the image size
USER root
RUN apk del native-deps
# Copy all the files into the container to make the container independent from local files
COPY . /src
RUN chown -R node:node /src/*
USER node
......
const request = require('supertest');
const db = require('../models/sequelize');
const app = require('./app');
describe('Auth service API tests', () => {
afterAll(() => {
app.close();
db.sequelize.close();
});
test('Should return a client error for #get /', async () => {
const response = await request(app)
.get('/');
expect(response.statusCode).toBe(404);
expect(response.body.code).toBe('ResourceNotFound');
});
});
const restify = require('restify');
require('./lib/config')();
const app = restify.createServer({ name: 'Auth Service' });
app.use(restify.plugins.bodyParser({ mapParams: true }));
require('./routes')(app);
module.exports = app;
const path = require('path');
const nconf = require('nconf');
const configRequired = require('../../../config/nconf/config_required');
const configDefault = require('../../../config/nconf/config_default');
module.exports = () => {
nconf.argv().env('__').file(path.join(__dirname, '../../../config/nconf/', `config_${(process.env.NODE_ENV || 'development')}.json`));
nconf.set('NODE_ENV', nconf.get('NODE_ENV') || 'development');
nconf.defaults(configDefault);
nconf.required(configRequired);
};
const jsonwt = require('jsonwebtoken');
const nconf = require('nconf');
const createAccessToken = (user) => {
const iatTime = Math.floor(Date.now() / 1000);
const expTime = iatTime + nconf.get('jwt:accessToken:expiration');
const payload = {
aud: nconf.get('jwt:accessToken:audience'),
iss: nconf.get('jwt:accessToken:issuer'),
sub: user.get('id'),
iat: iatTime,
exp: expTime,
};
const options = {
algorithm: 'HS256',
};
return jsonwt.sign(payload, nconf.get('jwt:accessToken:secret'), options);
};
module.exports = {
createAccessToken,
};
const jsonwt = require('jsonwebtoken');
const nconf = require('nconf');
const cookieConfig = {
path: nconf.get('cookie:path'),
maxAge: nconf.get('cookie:maxAge'),
secure: nconf.get('cookie:secure'),
httpOnly: nconf.get('cookie:httpOnly'),
sameSite: nconf.get('cookie:sameSite'),
};
const clearCookieConfig = {
path: nconf.get('cookie:path'),
maxAge: 0,
secure: nconf.get('cookie:secure'),
httpOnly: nconf.get('cookie:httpOnly'),
sameSite: nconf.get('cookie:sameSite'),
};
const cookieName = nconf.get('cookie:name');
const createRefreshToken = (user) => {
const iatTime = Math.floor(Date.now() / 1000);
const expTime = iatTime + nconf.get('jwt:refreshToken:expiration');
const payload = {
aud: nconf.get('jwt:refreshToken:audience'),
iss: nconf.get('jwt:refreshToken:issuer'),
sub: user.get('id'),
iat: iatTime,
exp: expTime,
};
const options = {
algorithm: 'HS256',
};
return jsonwt.sign(payload, nconf.get('jwt:refreshToken:secret'), options);
};
module.exports = {
createRefreshToken,
cookieConfig,
clearCookieConfig,
cookieName,
};
const nconf = require('nconf');
const cookie = require('cookie');
const tokenFromCookie = (req) => {
const cookies = cookie.parse(req.headers.cookie || '');
if (cookies && cookies[nconf.get('cookie:name')]) {
return cookies[nconf.get('cookie:name')];
}
return null;
};
module.exports = tokenFromCookie;
/* eslint-disable global-require */
module.exports = function (server) {
require('./login')(server);
require('./protected')(server);
};
const restifyAsyncWrap = require('@gilbertco/restify-async-wrap');
const postLogin = require('./post');
module.exports = (server) => {
server.post('/login', restifyAsyncWrap(postLogin));
};
/* eslint-disable global-require */
const request = require('supertest');
const nconf = require('nconf');
const cookie = require('cookie');
const db = require('../../../models/sequelize');
const app = require('../../app');
describe('Post /login API tests', () => {
afterAll(() => {
app.close();
db.sequelize.close();
});
test('Should return a client error for #post /login with wrong username', async () => {
const response = await request(app)
.post('/login')
.send({ username: 'InvalidUser', password: 'Password1' });
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return a client error for #post /login with wrong password', async () => {
const response = await request(app)
.post('/login')
.send({ username: 'FirstUser', password: 'WrongPassword' });
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return ok for #post /login with correct credentials', async () => {
const response = await request(app)
.post('/login')
.send({ username: 'FirstUser', password: 'Password1' });
expect(response.statusCode).toBe(200);
const cookies = cookie.parse(response.header['set-cookie'][0]);
expect(cookies[nconf.get('cookie:name')].length).toBeGreaterThan(20);
expect(cookies['Max-Age']).toBe(nconf.get('cookie:maxAge').toString());
expect(cookies.Path).toBe(nconf.get('cookie:path'));
expect(cookies.SameSite).toBe(nconf.get('cookie:sameSite'));
});
});
const errors = require('restify-errors');
const bcrypt = require('bcryptjs');
const db = require('../../../models/sequelize');
const refreshToken = require('../../lib/token/refreshToken');
const cookie = require('cookie');
const postLogin = async (req, res, next) => {
let user;
try {
user = await db.User.findOne({
where: { username: req.params.username },
});
} catch (e) {
return next(new errors.InternalServerError(e));
}
if (user === null) {
return next(new errors.InvalidCredentialsError());
}
if (bcrypt.compareSync(req.params.password, user.get('passwordHash'))) {
res.setHeader(
'Set-Cookie',
cookie.serialize(
refreshToken.cookieName,
refreshToken.createRefreshToken(user),
refreshToken.cookieConfig
)
);
res.send(200);
return next();
}
return next(new errors.InvalidCredentialsError());
};
module.exports = postLogin;
/* eslint-disable global-require */
const errors = require('restify-errors');
jest.mock('../../../models/sequelize');
const db = require('../../../models/sequelize');
describe('Post /login unit tests', () => {
test('Should handle db exceptions', async () => {
const mockRes = jest.fn();
const mockNext = jest.fn();
db.User.findOne = jest.fn(() => Promise.reject('I reject!'));
const postLogin = require('./post.js');
await postLogin(
{ params: { username: 'abba' } },
mockRes,
mockNext
);
expect(mockRes).toHaveBeenCalledTimes(0);
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext.mock.calls[0][0]).toEqual(new errors.InternalServerError('I reject!'));
});
});
/* eslint-disable global-require */
module.exports = function (server) {
require('./logout')(server);
require('./renew')(server);
};
const request = require('supertest');
const db = require('../../../../models/sequelize');
const app = require('../../../app');
const nconf = require('nconf');
// Created in https://jwt.io/
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTUyODAzNDk4NCwiZXhwIjoxODMxNTk5MDIyfQ.bgZHE0TZPunkx00XwqbGjWfVrnyu3xxS2_U7cZd4pNc';
describe('Get /logout API tests', () => {
afterAll(() => {
app.close();
db.sequelize.close();
});
test('Should return ok and clear cookie & token for #get /logout with valid refresh token', async () => {
const response = await request.agent(app)
.get('/protected/logout')
.set('Cookie', `${nconf.get('cookie:name')}=${validToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.accessToken).toBe('');
expect(response.header['set-cookie'][0]).toBe(`${nconf.get('cookie:name')}=; Max-Age=0; Path=/protected; HttpOnly; Secure; SameSite=Strict`);
});
test('Should return ok and clear cookie & token for #get /logout without refresh token', async () => {
const response = await request.agent(app)
.get('/protected/logout');
expect(response.statusCode).toBe(200);
expect(response.body.accessToken).toBe('');
});
});
const refreshToken = require('../../../lib/token/refreshToken');
const cookie = require('cookie');
const getLogout = async (req, res, next) => {
res.setHeader(
'Set-Cookie',
cookie.serialize(
refreshToken.cookieName,
'',
refreshToken.clearCookieConfig
)
);
res.send(200, { accessToken: '' });
return next();
};
module.exports = getLogout;
const jwt = require('restify-jwt-community');
const nconf = require('nconf');
const restifyAsyncWrap = require('@gilbertco/restify-async-wrap');
const tokenFromCookie = require('../../../lib/token/tokenFromCookie');
const getLogout = require('./get');
module.exports = (server) => {
server.get(
'/protected/logout',
jwt({
algorithms: ['HS256'],
audience: nconf.get('jwt:refreshToken:audience'),
issuer: nconf.get('jwt:refreshToken:issuer'),
secret: nconf.get('jwt:refreshToken:secret'),
credentialsRequired: false,
getToken: tokenFromCookie,
}),
restifyAsyncWrap(getLogout));
};
const request = require('supertest');
const db = require('../../../../models/sequelize');
const app = require('../../../app');
const nconf = require('nconf');
// Created in https://jwt.io/
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOjEsImlhdCI6MTUyODAzNDk4NCwiZXhwIjoxODMxNTk5MDIyfQ.bgZHE0TZPunkx00XwqbGjWfVrnyu3xxS2_U7cZd4pNc';
const invalidSubToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOjEyMzQ1LCJpYXQiOjE1MjgwMzQ5ODQsImV4cCI6MTgzMTU5OTAyMn0.HXhupEjQnsTOysI259z7-Y06l7YeKYX0pwzp1ayQjQI';
const invalidAudienceToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZWUuY29tIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmV4YW1wbGUuY29tIiwic3ViIjoxLCJpYXQiOjE1MjgwMzQ5ODQsImV4cCI6MTgzMTU5OTAyMn0.8l3a56QeCIG1sZMpuqvfReVIXp6UO7Q-ybTgw572ozo';
const invalidIssuerToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZWUuY29tIiwic3ViIjoxLCJpYXQiOjE1MjgwMzQ5ODQsImV4cCI6MTgzMTU5OTAyMn0.yKRj3dyIgPSmg86aVV0zX4-8j12pzUE0KNZCyOhcIMw';
const invalidNoneAlgToken = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZWUuY29tIiwic3ViIjoxLCJpYXQiOjE1MjgwMzQ5ODQsImV4cCI6MTgzMTU5OTAyMn0.';
const invalidHS384AlgToken = 'eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZWUuY29tIiwic3ViIjoxLCJpYXQiOjE1MjgwMzQ5ODQsImV4cCI6MTgzMTU5OTAyMn0.2aood-SQEIefvNDvV_AICl4Umw-sCgs3kGFM1sVz6Sap6q1T4_zbMDQgdMq4vzKe';
describe('Get /renew API tests', () => {
afterAll(() => {
app.close();
db.sequelize.close();
});
test('Should return a client error for #get /renew without proper refresh token', done => (
request(app)
.get('/protected/renew')
.expect(401, {
code: 'InvalidCredentials',
message: 'No authorization token was found',
})
.end((err) => {
if (err) return done.fail(err);
return done();
})
));
test('Should return a client error for #get /renew with non-existing user id (sub) in the token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${invalidSubToken}`);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return a new access token for #get /renew with proper refresh token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${validToken}`);
expect(response.statusCode).toBe(200);
expect(response.body.accessToken.length).toBeGreaterThan(20);
});
test('Should return a client error for #get /renew with invalid issuer field in token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${invalidIssuerToken}`);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return a client error for #get /renew with invalid audience field in token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${invalidAudienceToken}`);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return a client error for #get /renew with "HS384" algorithm field in the token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${invalidHS384AlgToken}`);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
test('Should return a client error for #get /renew with "none" algorithm field in the token', async () => {
const response = await request.agent(app)
.get('/protected/renew')
.set('Cookie', `${nconf.get('cookie:name')}=${invalidNoneAlgToken}`);
expect(response.statusCode).toBe(401);
expect(response.body.code).toBe('InvalidCredentials');
});
});
const errors = require('restify-errors');
const db = require('../../../../models/sequelize');
const accessToken = require('../../../lib/token/accessToken');
const getRenew = async (req, res, next) => {
let user;
try {
user = await db.User.findById(req.user.sub);
} catch (e) {
return next(new errors.InternalServerError(e));
}
if (user === null) {
return next(new errors.InvalidCredentialsError());
}
res.send(200, { accessToken: accessToken.createAccessToken(user) });
return next();
};
module.exports = getRenew;
/* eslint-disable global-require */
const errors = require('restify-errors');
jest.mock('../../../../models/sequelize');
const db = require('../../../../models/sequelize');
describe('Get /renew unit tests', () => {
test('Should handle db exceptions', async () => {
const mockRes = jest.fn();
const mockNext = jest.fn();
db.User.findById = jest.fn(() => Promise.reject('I reject also!'));
const postLogin = require('./get.js');
await postLogin(
{ user: { sub: 1 } },
mockRes,
mockNext
);
expect(mockRes).toHaveBeenCalledTimes(0);
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext.mock.calls[0][0]).toEqual(new errors.InternalServerError('I reject also!'));
});
});
const jwt = require('restify-jwt-community');
const nconf = require('nconf');
const restifyAsyncWrap = require('@gilbertco/restify-async-wrap');
const tokenFromCookie = require('../../../lib/token/tokenFromCookie');
const getRenew = require('./get');
module.exports = (server) => {
server.get(
'/protected/renew',
jwt({
algorithms: ['HS256'],
audience: nconf.get('jwt:refreshToken:audience'),
issuer: nconf.get('jwt:refreshToken:issuer'),
secret: nconf.get('jwt:refreshToken:secret'),
getToken: tokenFromCookie,
}),
restifyAsyncWrap(getRenew));
};
......@@ -4,6 +4,10 @@
"app/**/*.js",
"index.js"
],
"coveragePathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/app/lib/config/"
],
"coverageDirectory": "shippable/codecoverage",
"coverageReporters": [
"text",
......
const defaultValues = {
http: {
port: 3100,
},
jwt: {
accessToken: {
expiration: 1800, // 1800 = 60 * 60 * 24 * 8 is 30 minutes
audience: 'https://app.example.com',
issuer: 'https://auth.example.com',
},
refreshToken: {
expiration: 2592000, // 2592000 = 60 * 60 * 24 * 30 is 30 days
audience: 'https://auth.example.com',
issuer: 'https://auth.example.com',
},
},
cookie: {
name: 'refreshToken',
path: '/protected',
maxAge: 2592000, // 2592000 = 60 * 60 * 24 * 30 is 30 days
secure: true,
httpOnly: true,
sameSite: 'Strict',
},
};
module.exports = defaultValues;
const requiredValues = [
'NODE_ENV',
'http:port',
'jwt:accessToken:secret',
'jwt:accessToken:expiration',
'jwt:accessToken:audience',
'jwt:accessToken:issuer',
'jwt:refreshToken:secret',
'jwt:refreshToken:expiration',
'jwt:refreshToken:audience',
'jwt:refreshToken:issuer',
'cookie:path',
'cookie:maxAge',
'cookie:secure',
'cookie:httpOnly',
'cookie:sameSite',
];
module.exports = requiredValues;
{
"development": {
"username": "postgres",
"password": null,
"database": "article_development",
"host": "127.0.0.1",
"dialect": "postgres",
"timezone": "utc",
"logging": true,
"operatorsAliases": false
},
"continuous_integration": {
"username": "postgres",
"password": null,
"database": "article_ci",
"host": "127.0.0.1",
"dialect": "postgres",
"timezone": "utc",
"logging": false,
"operatorsAliases": false
},
"production": {
"username": "postgres",
"password": null,
"database": "article_production",
"host": "db-auth",
"dialect": "postgres",
"timezone": "utc",
"logging": false,
"use_env_variable": "DATABASE_URL",
"operatorsAliases": false
}
}
module.exports = {
up: (queryInterface, Sequelize) => (
queryInterface.createTable('Users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
username: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
passwordHash: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
})
),
down: queryInterface => queryInterface.dropTable('Users'),
};
module.exports = {
up: queryInterface => (
queryInterface.bulkInsert('Users', [{
username: 'FirstUser',
passwordHash: '$2y$12$M9D5R21X.6TJR8WJ66af6eUyBfYwVgjJibcOcwhptvfp6a8.46yD6', // "Password1"
updatedAt: new Date(),
createdAt: new Date(),
}], {})
),
down: queryInterface => (
queryInterface.bulkDelete('Users', {
username: ['FirstUser'],
})
),
};
......@@ -16,6 +16,18 @@ module.exports = function (grunt) {
test_unit_only: {
exec: 'jest --config config/jest/jest_unit_tests.json',
},
drop_db: {
exec: 'sequelize db:drop || true',
},
create_db: {
exec: 'sequelize db:create',
},
init_db