Merge branch 'master' into bug-killing-spree

This commit is contained in:
Kiwi
2017-06-22 00:47:27 +07:00
committed by GitHub
15 changed files with 487 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@ program
.command('assets', 'interact with assets')
.command('setup', 'setup the application')
.command('jobs', 'work with the job queues')
.command('token', 'work with the access tokens')
.command('users', 'work with the application auth')
.command('migration', 'provides utilities for migrating the database')
.command('plugins', 'provides utilities for interacting with the plugin system')
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const program = require('./commander');
const mongoose = require('../services/mongoose');
const TokensService = require('../services/tokens');
const util = require('./util');
const Table = require('cli-table');
// Register the shutdown criteria.
util.onshutdown([
() => mongoose.disconnect()
]);
async function listTokens(userID) {
try {
let tokens = await TokensService.list(userID);
let table = new Table({
head: [
'ID',
'Name',
'Status'
]
});
tokens.forEach((token) => {
table.push([
token.id,
token.name,
token.active ? 'Active' : 'Revoked'
]);
});
console.log(table.toString());
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function revokeToken(tokenID) {
try {
await TokensService.revoke(null, tokenID);
console.log(`Revoked Token[${tokenID}]`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
async function createToken(userID, tokenName) {
try {
let {pat: {id}, jwt} = await TokensService.create(userID, tokenName);
console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`);
util.shutdown();
} catch (e) {
console.error(e);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
program
.command('list <userID>')
.description('list tokens for a user')
.action(listTokens);
program
.command('revoke <tokenID>')
.description('revokes a token with a given id')
.action(revokeToken);
program
.command('create <userID> <tokenName>')
.description('create a token for a user with a given name')
.action(createToken);
program.parse(process.argv);
// If there is no command listed, output help.
if (!process.argv.slice(2).length) {
program.outputHelp();
util.shutdown();
}
+2
View File
@@ -4,6 +4,7 @@ const debug = require('debug')('talk:graph:mutators');
const Comment = require('./comment');
const Action = require('./action');
const Tag = require('./tag');
const Token = require('./token');
const User = require('./user');
const plugins = require('../../services/plugins');
@@ -14,6 +15,7 @@ let mutators = [
Comment,
Action,
Tag,
Token,
User,
// Load the plugin mutators from the manager.
+41
View File
@@ -0,0 +1,41 @@
const errors = require('../../errors');
const TokensService = require('../../services/tokens');
const {
CREATE_TOKEN,
REVOKE_TOKEN
} = require('../../perms/constants');
// Creates a new token for a user.
const createToken = async ({user}, {name}) => {
let {pat, jwt} = await TokensService.create(user.id, name);
// Attach the token to the PAT.
pat.jwt = jwt;
// Return that PAT!
return pat;
};
// Revokes the token from the user.
const revokeToken = async ({user}, {id}) => {
return TokensService.revoke(user.id, id);
};
module.exports = (context) => {
let mutators = {
Token: {
create: () => Promise.reject(errors.ErrNotAuthorized),
revoke: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (context.user && context.user.can(CREATE_TOKEN)) {
mutators.Token.create = (input) => createToken(context, input);
}
if (context.user && context.user.can(REVOKE_TOKEN)) {
mutators.Token.revoke = (input) => revokeToken(context, input);
}
return mutators;
};
+6
View File
@@ -39,6 +39,12 @@ const RootMutation = {
},
removeTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.remove(tag));
},
createToken(_, {input}, {mutators: {Token}}) {
return wrapResponse('token')(Token.create(input));
},
revokeToken(_, {input}, {mutators: {Token}}) {
return wrapResponse(null)(Token.revoke(input));
}
};
+9 -1
View File
@@ -5,7 +5,8 @@ const {
SEARCH_OTHER_USERS,
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS
SEARCH_COMMENT_METRICS,
LIST_OWN_TOKENS
} = require('../../perms/constants');
const User = {
@@ -46,6 +47,13 @@ const User = {
return null;
},
tokens({id, tokens}, args, {user}) {
if (!user || ((user.id !== id) && !user.can(LIST_OWN_TOKENS))) {
return null;
}
return tokens;
},
ignoredUsers({id}, args, {user, loaders: {Users}}) {
// Only allow a logged in user that is either the current user or is a staff
+57
View File
@@ -36,6 +36,23 @@ enum USER_ROLES {
MODERATOR
}
# Token is a personal access token associated with a given user.
type Token {
# ID is the unique identifier for the token.
id: ID!
# Name is the description for the token.
name: String!
# Active determines if the token is available to hit the API.
active: Boolean!
# JWT is the actual token to use for authentication, this is only available
# on token creation, otherwise it will be null.
jwt: String
}
type UserProfile {
# the id is an identifier for the user profile (email, facebook id, etc)
id: String!
@@ -78,6 +95,9 @@ type User {
# ignored users.
ignoredUsers: [User!]
# Tokens are the personal access tokens for a given user.
tokens: [Token!]
# returns all comments based on a query.
comments(query: CommentsQuery): CommentConnection!
@@ -906,6 +926,37 @@ type EditCommentResponse implements Response {
errors: [UserError!]
}
# CreateTokenInput contains the input to create the token.
input CreateTokenInput {
# Name is the description for the token.
name: String!
}
# CreateTokenResponse contains the errors related to creating a token.
type CreateTokenResponse implements Response {
# Token is the Token that was created, or null if it failed.
token: Token
# An array of errors relating to the mutation that occured.
errors: [UserError!]
}
# RevokeTokenInput contains the input to revoke the token.
input RevokeTokenInput {
# ID is the JTI for the token.
id: ID!
}
# RevokeTokenResponse contains the errors related to revoking a token.
type RevokeTokenResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -945,6 +996,12 @@ type RootMutation {
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
# CreateToken will create a token that is attached to the current user.
createToken(input: CreateTokenInput!): CreateTokenResponse!
# RevokeToken will revoke an existing token.
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse!
# Stop Ignoring comments by another user
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
}
+16
View File
@@ -0,0 +1,16 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const TokenSchema = new Schema({
// ID is the JTI of a given JWT's identifier.
id: String,
// Name is given by the user on token creation.
name: String,
// Active is used to determine if the token is valid.
active: Boolean
});
module.exports = TokenSchema;
+4
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./schema/tag_link');
const TokenSchema = require('./schema/token');
const intersection = require('lodash/intersection');
const can = require('../perms');
@@ -86,6 +87,9 @@ const UserSchema = new Schema({
// addresses.
profiles: [ProfileSchema],
// Tokens are the individual personal access tokens for a given user.
tokens: [TokenSchema],
// Roles provides an array of roles (as strings) that is associated with a
// user.
roles: [{
+3
View File
@@ -14,6 +14,8 @@ module.exports = {
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
UPDATE_CONFIG: 'UPDATE_CONFIG',
CREATE_TOKEN: 'CREATE_TOKEN',
REVOKE_TOKEN: 'REVOKE_TOKEN',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
@@ -22,6 +24,7 @@ module.exports = {
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
// subscriptions
+4
View File
@@ -29,6 +29,10 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.UPDATE_CONFIG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.CREATE_TOKEN:
return check(user, ['ADMIN']);
case types.REVOKE_TOKEN:
return check(user, ['ADMIN']);
default:
break;
}
+2
View File
@@ -15,6 +15,8 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
default:
+16 -4
View File
@@ -1,6 +1,7 @@
const passport = require('passport');
const UsersService = require('./users');
const SettingsService = require('./settings');
const TokensService = require('./tokens');
const fetch = require('node-fetch');
const FormData = require('form-data');
const JWT = require('jsonwebtoken');
@@ -157,10 +158,7 @@ const HandleLogout = (req, res, next) => {
});
};
/**
* Check if the given token is already blacklisted, throw an error if it is.
*/
const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
const checkGeneralTokenBlacklist = (jwt) => new Promise((resolve, reject) => {
client.get(`jtir[${jwt.jti}]`, (err, expiry) => {
if (err) {
return reject(err);
@@ -174,6 +172,20 @@ const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
});
});
/**
* Check if the given token is already blacklisted, throw an error if it is.
*/
const CheckBlacklisted = async (jwt) => {
// Check to see if this is a PAT.
if (jwt.pat) {
return TokensService.validate(jwt.sub, jwt.jti);
}
// It wasn't a PAT! Check to see if it is valid anyways.
return checkGeneralTokenBlacklist(jwt);
};
const jwt = require('jsonwebtoken');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
+127
View File
@@ -0,0 +1,127 @@
const errors = require('../errors');
const UserModel = require('../models/user');
const JWT = require('jsonwebtoken');
const uuid = require('uuid');
const {
JWT_SECRET,
JWT_ISSUER,
JWT_AUDIENCE
} = require('../config');
/**
* TokenService manages Personal Access Tokens for users. These tokens are
* persisted in the database and attached to the user.
*/
module.exports = class TokenService {
/**
* Creates a token for a user with a given name.
*
* @param {String} userID the id of the user owning the token
* @param {String} tokenName the name of the token to be created
*/
static async create(userID, tokenName) {
// Create the token.
const payload = {
jti: uuid.v4(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: userID,
pat: true
};
// Sign the payload.
const jwt = JWT.sign(payload, JWT_SECRET, {});
// Create the PAT.
let pat = {
id: payload.jti,
name: tokenName,
active: true
};
// Wait to update the user model with the new PAT.
await UserModel.update({id: userID}, {
$push: {
tokens: pat
}
});
return {payload, jwt, pat};
}
/**
* Revokes a token and prevents the token from being used. Once a token has
* been revoked, it cannot be re-enabled.
*
* @param {String} userID the id of the user owning the token
* @param {String} tokenID the jti of the token to revoke
*/
static async revoke(userID, tokenID) {
let query = {
tokens: {
$elemMatch: {
id: tokenID
}
}
};
if (userID) {
query.id = userID;
}
// Revoke the token id.
await UserModel.update(query, {
$set: {
'tokens.$.active': false
}
});
}
/**
* Validate that a given Token is valid.
*
* @param {String} userID the user's id that owns the token
* @param {String} tokenID the id of the token
*/
static async validate(userID, tokenID) {
// Find the user.
let user = await UserModel.findOne({
id: userID
}).select('tokens');
if (!user || !user.tokens) {
throw new errors.ErrAuthentication('user does not exist');
}
// Extract the token from the user.
let token = user.tokens.find(({id}) => id === tokenID);
if (!token) {
throw new errors.ErrAuthentication('token does not exist');
}
// Check to see if it is active.
if (!token.active) {
throw new errors.ErrAuthentication('token is not active');
}
}
/**
* Lists the tokens owned by the user.
*
* @param {String} userID the id of the user owning the token
*/
static async list(userID) {
// Get the user specified by the id.
let user = await UserModel.findOne({id: userID}).select('tokens');
if (!user || !user.tokens) {
return [];
}
return user.tokens;
}
};
+99
View File
@@ -0,0 +1,99 @@
const TokensService = require('../../../services/tokens');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
const expect = chai.expect;
describe('services.TokensService', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('sockmonster@gmail.com', '2Coral!!', 'Sockmonster');
});
describe('#create', () => {
it('can create the token without error', async () => {
let token = await TokensService.create(user.id, 'Github Token');
expect(token).to.be.an.object;
expect(token.jwt).to.be.a.string;
expect(token.pat).to.be.an.object;
let pat = token.pat;
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', pat.id);
expect(tokens[0]).to.have.property('name', pat.name);
});
});
describe('#revoke', () => {
it('can revoke a token', async () => {
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
expect(tokens[0]).to.have.property('active', true);
await TokensService.revoke(user.id, id);
tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
expect(tokens[0]).to.have.property('active', false);
});
});
describe('#validate', () => {
it('will allow a valid token', async () => {
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
// Validate it.
await TokensService.validate(user.id, id);
});
it('will not allow an invalid token', async () => {
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
// Revoke it.
await TokensService.revoke(user.id, id);
// Validate it.
return TokensService.validate(user.id, id).should.eventually.be.rejected;
});
});
describe('#list', () => {
it('lists the tokens for a user', async () => {
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(0);
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
});
});
});