some access control

This commit is contained in:
Andris Reinman 2018-08-29 13:15:38 +03:00
parent beca0b1e33
commit 1ad43a267e
7 changed files with 439 additions and 206 deletions

143
api.js
View file

@ -10,8 +10,8 @@ const MessageHandler = require('./lib/message-handler');
const ImapNotifier = require('./lib/imap-notifier');
const db = require('./lib/db');
const certs = require('./lib/certs');
const ObjectID = require('mongodb').ObjectID;
const rootUser = new ObjectID('0'.repeat(24));
const tools = require('./lib/tools');
const crypto = require('crypto');
const usersRoutes = require('./lib/api/users');
const addressesRoutes = require('./lib/api/addresses');
@ -80,56 +80,111 @@ server.use(
})
);
server.use((req, res, next) => {
let accessToken = req.query.accessToken || req.headers['x-access-token'] || false;
if (req.query.accessToken) {
delete req.query.accessToken;
}
server.use(
tools.asyncifyJson(async (req, res, next) => {
let accessToken = req.query.accessToken || req.headers['x-access-token'] || false;
let tokenRequired = false;
let fail = () => {
res.status(403);
res.charSet('utf-8');
return res.json({
error: 'Invalid accessToken value',
code: 'InvalidToken'
});
};
req.validate = permission => {
if (!permission.granted) {
let err = new Error('Not enough privileges');
err.responseCode = 403;
err.code = 'MissingPrivileges';
throw err;
if (req.query.accessToken) {
req.query.accessToken = '';
}
};
// hard coded master token
if (config.api.accessToken) {
tokenRequired = true;
if (config.api.accessToken === accessToken) {
req.role = 'root';
req.user = rootUser;
return next();
if (req.headers['x-access-token']) {
req.headers['x-access-token'] = '';
}
}
// TODO: dynamically allocated tokens
let tokenRequired = false;
if (tokenRequired) {
// no valid token found
return fail();
}
let fail = () => {
res.status(403);
res.charSet('utf-8');
return res.json({
error: 'Invalid accessToken value',
code: 'InvalidToken'
});
};
// allow all
req.role = 'root';
req.user = rootUser;
next();
});
req.validate = permission => {
if (!permission.granted) {
let err = new Error('Not enough privileges');
err.responseCode = 403;
err.code = 'MissingPrivileges';
throw err;
}
};
logger.token('user', req => (req.user && req.user.toString()) || '?'.repeat(24));
// hard coded master token
if (config.api.accessToken) {
tokenRequired = true;
if (config.api.accessToken === accessToken) {
req.role = 'root';
req.user = 'root';
return next();
}
}
if (config.api.accessControl.enabled) {
tokenRequired = true;
if (accessToken && accessToken.length === 40 && /^[a-fA-F0-9]{40}$/.test(accessToken)) {
let tokenData;
let tokenHash = crypto
.createHash('sha256')
.update(accessToken)
.digest('hex');
try {
let key = 'tn:token:' + tokenHash;
tokenData = await db.redis.hgetall(key);
} catch (err) {
err.responseCode = 500;
err.code = 'InternalDatabaseError';
throw err;
}
if (tokenData && tokenData.user && tokenData.role && config.api.roles[tokenData.role]) {
let signature = crypto
.createHmac('sha256', config.api.accessControl.secret)
.update(
JSON.stringify({
token: accessToken,
user: tokenData.user,
role: tokenData.role
})
)
.digest('hex');
if (signature !== tokenData.s) {
// rogue token
try {
await db.redis
.multi()
.del('tn:token:' + tokenHash)
.srem('tn:user:' + tokenData.user, tokenHash)
.exec();
} catch (err) {
// ignore
}
} else {
req.role = tokenData.role;
req.user = tokenData.user;
}
return next();
}
}
}
if (tokenRequired) {
// no valid token found
return fail();
}
// allow all
req.role = 'root';
req.user = 'root';
next();
})
);
logger.token('user', req => (req.user && req.user.toString()) || '-');
logger.token('url', req => {
if (/\baccessToken=/.test(req.url)) {
return req.url.replace(/\baccessToken=[^&]+/g, 'accessToken=' + 'x'.repeat(6));

View file

@ -1,5 +1,7 @@
#!/usr/bin/env node
/* eslint no-console: 0*/
'use strict';
// FUTURE FEATURE
@ -7,17 +9,141 @@
//const config = require('wild-config');
const db = require('../lib/db');
const errors = require('../lib/errors');
const log = require('npmlog');
const crypto = require('crypto');
const config = require('wild-config');
const yargs = require('yargs');
const util = require('util');
// Initialize database connection
db.connect(err => {
if (err) {
log.error('Db', 'Failed to setup database connection');
errors.notify(err);
return setTimeout(() => process.exit(1), 3000);
}
const dbconnect = util.promisify(db.connect);
log.info('Future feature');
process.exit();
});
let argv = yargs
.usage('Usage: $0 <command> [options]')
.command(
'provision <user> <role>',
'Provision new access token',
yargs =>
yargs
.option('user', {
alias: 'u',
describe: 'Username',
demandOption: true
})
.option('role', {
alias: 'r',
describe: 'Choose role',
choices: Object.keys(config.api.roles),
demandOption: true
}),
async argv => {
await dbconnect();
let accessToken = crypto.randomBytes(20).toString('hex');
let tokenHash = crypto
.createHash('sha256')
.update(accessToken)
.digest('hex');
let key = 'tn:token:' + tokenHash;
let tokenData = {
user: argv.user,
role: argv.role,
created: Date.now(),
// signature
s: crypto
.createHmac('sha256', config.api.accessControl.secret)
.update(
JSON.stringify({
token: accessToken,
user: argv.user,
role: argv.role
})
)
.digest('hex')
};
await db.redis
.multi()
.hmset(key, tokenData)
.sadd('tn:user:' + argv.user, tokenHash)
.exec();
console.error('Generated access token for %s[%s]:', tokenData.user, tokenData.role);
process.stdout.write(accessToken);
console.error('');
process.exit();
}
)
.command(
'delete <token>',
'Delete existing access token',
yargs =>
yargs.option('token', {
alias: 't',
describe: 'Access token',
demandOption: true
}),
async argv => {
await dbconnect();
let accessToken = argv.token;
let tokenHash = crypto
.createHash('sha256')
.update(accessToken)
.digest('hex');
let key = 'tn:token:' + tokenHash;
let tokenData = await db.redis.hgetall(key);
if (tokenData) {
await db.redis.del(key);
if (tokenData.user) {
await db.redis.srem('tn:user:' + tokenData.user, tokenHash);
}
console.error('Token deleted');
} else {
console.error('Token not found');
}
process.exit();
}
)
.command(
'clear <user>',
'Delete all tokens for an user',
yargs =>
yargs.option('user', {
alias: 'u',
describe: 'User to de-provision',
demandOption: true
}),
async argv => {
await dbconnect();
let user = argv.user;
let tokens = await db.redis.smembers('tn:user:' + user);
if (!tokens || !tokens.length) {
console.error('No tokens found for %s', user);
process.exit();
}
let query = db.redis.multi().del('tn:user:' + user);
tokens.forEach(tokenHash => {
query = query.del('tn:token:' + tokenHash);
});
await query.exec();
console.error('Deleted %s tokens for %s', tokens.length, user);
process.exit();
}
)
.help().argv;
if (argv) {
// ignore
}

View file

@ -13,11 +13,9 @@ secure=false
[accessControl]
# If true then require a valid access token to perform API calls
enabled=false
# If enabled then encrypt access tokens with the secret password. By default the tokens
# are not encrypted and stored as cleartext. Once set up do not change these values,
# otherwise decrypting tokens is going to fail
#cipher="aes192"
#secret="a secret cat"
# Secret for HMAC
# Changing this value invalidates all tokens
secret="a secret cat"
[roles]
# @include "roles.json"

View file

@ -5,6 +5,17 @@
"read:any": ["*"],
"update:any": ["*"],
"delete:any": ["*"]
},
"authentication": {
"create:any": ["*"],
"read:any": ["*"]
}
},
"auth": {
"authentication": {
"create:any": ["*"]
}
}
}

View file

@ -75,8 +75,6 @@ module.exports = (db, server) => {
server.get(
{ name: 'addresses', path: '/addresses' },
tools.asyncifyJson(async (req, res, next) => {
req.validate(roles.can(req.role).readAny('addresses'));
res.charSet('utf-8');
const schema = Joi.object().keys({
@ -121,6 +119,9 @@ module.exports = (db, server) => {
return next();
}
// permissions check
req.validate(roles.can(req.role).readAny('addresses'));
let query = result.value.query;
let limit = result.value.limit;
let page = result.value.page;
@ -335,7 +336,7 @@ module.exports = (db, server) => {
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user.equals(user)) {
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).createOwn('addresses'));
} else {
req.validate(roles.can(req.role).createAny('addresses'));
@ -575,7 +576,7 @@ module.exports = (db, server) => {
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user.equals(user)) {
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('addresses'));
} else {
req.validate(roles.can(req.role).readAny('addresses'));
@ -726,7 +727,7 @@ module.exports = (db, server) => {
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user.equals(user)) {
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('addresses'));
} else {
req.validate(roles.can(req.role).readAny('addresses'));
@ -886,7 +887,7 @@ module.exports = (db, server) => {
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user.equals(user)) {
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).updateOwn('addresses'));
} else {
req.validate(roles.can(req.role).updateAny('addresses'));
@ -1140,7 +1141,7 @@ module.exports = (db, server) => {
let user = new ObjectID(result.value.user);
// permissions check
if (req.user && req.user.equals(user)) {
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).deleteOwn('addresses'));
} else {
req.validate(roles.can(req.role).deleteAny('addresses'));

View file

@ -4,8 +4,20 @@ const Joi = require('../joi');
const MongoPaging = require('mongo-cursor-pagination');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
const util = require('util');
const roles = require('../roles');
module.exports = (db, server, userHandler) => {
const authenticate = util.promisify((...args) => {
let callback = args.pop();
userHandler.authenticate(...args, (err, authData, user) => {
if (err) {
return callback(err);
}
return callback({ authData, user });
});
});
/**
* @api {post} /authenticate Authenticate an User
* @apiName PostAuth
@ -64,63 +76,73 @@ module.exports = (db, server, userHandler) => {
* "id": "5b22283d45e8d47572eb0381"
* }
*/
server.post('/authenticate', (req, res, next) => {
res.charSet('utf-8');
server.post(
'/authenticate',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
username: Joi.alternatives()
.try(
Joi.string()
.lowercase()
.regex(/^[a-z0-9][a-z0-9.]+[a-z0-9]$/, 'username')
.min(3)
.max(30),
Joi.string().email()
)
.required(),
password: Joi.string()
.max(256)
.required(),
const schema = Joi.object().keys({
username: Joi.alternatives()
.try(
Joi.string()
.lowercase()
.regex(/^[a-z0-9][a-z0-9.]+[a-z0-9]$/, 'username')
.min(3)
.max(30),
Joi.string().email()
)
.required(),
password: Joi.string()
.max(256)
.required(),
protocol: Joi.string().default('API'),
scope: Joi.string().default('master'),
protocol: Joi.string().default('API'),
scope: Joi.string().default('master'),
appId: Joi.string()
.empty('')
.uri(),
appId: Joi.string()
.empty('')
.uri(),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
});
return next();
}
let meta = {
protocol: result.value.protocol,
sess: result.value.sess,
ip: result.value.ip
};
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
});
if (result.value.appId) {
meta.appId = result.value.appId;
}
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
userHandler.authenticate(result.value.username, result.value.password, result.value.scope, meta, (err, authData, user) => {
if (err) {
// permissions check
req.validate(roles.can(req.role).createAny('authentication'));
let meta = {
protocol: result.value.protocol,
sess: result.value.sess,
ip: result.value.ip
};
if (result.value.appId) {
meta.appId = result.value.appId;
}
let authData, user;
try {
let auth = await authenticate(result.value.username, result.value.password, result.value.scope, meta);
authData = auth.authData;
user = auth.user;
} catch (err) {
let response = {
error: err.message
};
@ -162,8 +184,8 @@ module.exports = (db, server, userHandler) => {
res.json(authResponse);
return next();
});
});
})
);
/**
* @api {get} /users/:user/authlog List authentication Events
@ -283,6 +305,13 @@ module.exports = (db, server, userHandler) => {
return next();
}
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('authentication'));
} else {
req.validate(roles.can(req.role).readAny('authentication'));
}
let user = new ObjectID(result.value.user);
let limit = result.value.limit;
@ -424,96 +453,108 @@ module.exports = (db, server, userHandler) => {
* "error": "Database error"
* }
*/
server.get('/users/:user/authlog/:event', (req, res, next) => {
res.charSet('utf-8');
server.get(
'/users/:user/authlog/:event',
tools.asyncifyJson(async (req, res, next) => {
res.charSet('utf-8');
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
event: Joi.string()
.hex()
.lowercase()
.length(24)
.required()
});
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true,
allowUnknown: false
});
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
const schema = Joi.object().keys({
user: Joi.string()
.hex()
.lowercase()
.length(24)
.required(),
event: Joi.string()
.hex()
.lowercase()
.length(24)
.required()
});
return next();
}
let user = new ObjectID(result.value.user);
let event = new ObjectID(result.value.event);
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true,
allowUnknown: false
});
db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
_id: true
}
},
(err, userData) => {
if (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let filter = { _id: event, user };
db.database.collection('authlog').findOne(filter, (err, eventData) => {
if (err) {
res.json({
error: err.message
});
return next();
}
if (!eventData) {
res.json({
error: 'Event was not found',
code: 'EventNotFound'
});
return next();
}
let response = {
success: true,
id: eventData._id
};
Object.keys(eventData).forEach(key => {
if (!['_id', 'user'].includes(key)) {
response[key] = eventData[key];
}
});
res.json(response);
return next();
if (result.error) {
res.json({
error: result.error.message,
code: 'InputValidationError'
});
return next();
}
);
});
// permissions check
if (req.user && req.user === result.value.user) {
req.validate(roles.can(req.role).readOwn('authentication'));
} else {
req.validate(roles.can(req.role).readAny('authentication'));
}
let user = new ObjectID(result.value.user);
let event = new ObjectID(result.value.event);
let userData;
try {
userData = await db.users.collection('users').findOne(
{
_id: user
},
{
projection: {
_id: true
}
}
);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!userData) {
res.json({
error: 'This user does not exist',
code: 'UserNotFound'
});
return next();
}
let filter = { _id: event, user };
let eventData;
try {
eventData = await db.database.collection('authlog').findOne(filter);
} catch (err) {
res.json({
error: 'MongoDB Error: ' + err.message,
code: 'InternalDatabaseError'
});
return next();
}
if (!eventData) {
res.json({
error: 'Event was not found',
code: 'EventNotFound'
});
return next();
}
let response = {
success: true,
id: eventData._id
};
Object.keys(eventData).forEach(key => {
if (!['_id', 'user'].includes(key)) {
response[key] = eventData[key];
}
});
res.json(response);
return next();
})
);
};

View file

@ -75,7 +75,8 @@
"u2f": "0.1.3",
"utf7": "1.0.2",
"uuid": "3.3.2",
"wild-config": "1.3.6"
"wild-config": "1.3.6",
"yargs": "^12.0.1"
},
"repository": {
"type": "git",