From b78d4f29eacffc49518735729a25f29d95b9fb81 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 15 Jun 2017 18:12:53 -0400 Subject: [PATCH 1/3] Added PAT support --- bin/cli-token | 98 ++++++++++++++++++++++++++++++++ graph/mutators/index.js | 2 + graph/mutators/token.js | 41 +++++++++++++ graph/resolvers/root_mutation.js | 6 ++ graph/resolvers/user.js | 10 +++- graph/typeDefs.graphql | 57 +++++++++++++++++++ models/schema/token.js | 16 ++++++ models/user.js | 4 ++ perms/constants.js | 5 +- perms/mutationReducer.js | 4 ++ perms/queryReducer.js | 2 + services/passport.js | 18 ++++++ services/tokens.js | 82 ++++++++++++++++++++++++++ 13 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 bin/cli-token create mode 100644 graph/mutators/token.js create mode 100644 models/schema/token.js create mode 100644 services/tokens.js diff --git a/bin/cli-token b/bin/cli-token new file mode 100644 index 000000000..f6b4570a7 --- /dev/null +++ b/bin/cli-token @@ -0,0 +1,98 @@ +#!/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'); + +// Regeister 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); + + 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 ') + .description('list tokens for a user') + .action(listTokens); + +program + .command('revoke ') + .description('revokes a token with a given id') + .action(revokeToken); + +program + .command('create ') + .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(); +} diff --git a/graph/mutators/index.js b/graph/mutators/index.js index 0076a5e8f..e7e5df9e7 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -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. diff --git a/graph/mutators/token.js b/graph/mutators/token.js new file mode 100644 index 000000000..2a1028596 --- /dev/null +++ b/graph/mutators/token.js @@ -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; +}; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index f192405cc..dec0553a4 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -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)); } }; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 5f6fc52a1..035af22cb 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -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 diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 117b7e077..b69051697 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -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! @@ -889,6 +909,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 { @@ -928,6 +979,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 } diff --git a/models/schema/token.js b/models/schema/token.js new file mode 100644 index 000000000..83b6cc049 --- /dev/null +++ b/models/schema/token.js @@ -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; diff --git a/models/user.js b/models/user.js index fb05a5e28..b19a87b0a 100644 --- a/models/user.js +++ b/models/user.js @@ -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: [{ diff --git a/perms/constants.js b/perms/constants.js index 2b5b907ac..e843a17c7 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -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', @@ -21,5 +23,6 @@ module.exports = { SEARCH_ACTIONS: 'SEARCH_ACTIONS', SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS', SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS', - SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS' + SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS', + LIST_OWN_TOKENS: 'LIST_OWN_TOKENS' }; diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js index 53cece51f..897217348 100644 --- a/perms/mutationReducer.js +++ b/perms/mutationReducer.js @@ -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; } diff --git a/perms/queryReducer.js b/perms/queryReducer.js index 0e5054788..ba7e2b206 100644 --- a/perms/queryReducer.js +++ b/perms/queryReducer.js @@ -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']); default: break; } diff --git a/services/passport.js b/services/passport.js index 1d031c39c..bfd6bca45 100644 --- a/services/passport.js +++ b/services/passport.js @@ -34,6 +34,23 @@ const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { audience: JWT_AUDIENCE }); +// GeneratePersonalAccessToken will sign a token to include all the +// authorization information needed for the front end for headless access. +const GeneratePersonalAccessToken = (userID) => { + 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, {}); + + return {payload, jwt}; +}; + // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { const browser = bowser._detect(req.headers['user-agent']); @@ -474,5 +491,6 @@ module.exports = { HandleAuthPopupCallback, HandleGenerateCredentials, HandleLogout, + GeneratePersonalAccessToken, CheckBlacklisted }; diff --git a/services/tokens.js b/services/tokens.js new file mode 100644 index 000000000..eeefce175 --- /dev/null +++ b/services/tokens.js @@ -0,0 +1,82 @@ +const UserModel = require('../models/user'); +const {GeneratePersonalAccessToken} = require('./passport'); + +/** + * 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. + let {payload, jwt} = GeneratePersonalAccessToken(userID); + + // 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 + } + }); + } + + /** + * 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; + } + +}; From 393ad8530aab7b31a11d9a4db0cf48b275e9c490 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 19 Jun 2017 08:55:08 -0600 Subject: [PATCH 2/3] added tests, fixed some services --- bin/cli | 1 + bin/cli-token | 2 + services/passport.js | 38 ++++++------- services/tokens.js | 49 ++++++++++++++++- test/server/services/tokens.js | 99 ++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 24 deletions(-) mode change 100644 => 100755 bin/cli-token create mode 100644 test/server/services/tokens.js diff --git a/bin/cli b/bin/cli index 1f3d50ad2..33560c53b 100755 --- a/bin/cli +++ b/bin/cli @@ -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') diff --git a/bin/cli-token b/bin/cli-token old mode 100644 new mode 100755 index f6b4570a7..50335d925 --- a/bin/cli-token +++ b/bin/cli-token @@ -49,6 +49,8 @@ async function revokeToken(tokenID) { await TokensService.revoke(null, tokenID); + console.log(`Revoked Token[${tokenID}]`); + util.shutdown(); } catch (e) { console.error(e); diff --git a/services/passport.js b/services/passport.js index bfd6bca45..92cd914b3 100644 --- a/services/passport.js +++ b/services/passport.js @@ -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'); @@ -34,23 +35,6 @@ const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { audience: JWT_AUDIENCE }); -// GeneratePersonalAccessToken will sign a token to include all the -// authorization information needed for the front end for headless access. -const GeneratePersonalAccessToken = (userID) => { - 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, {}); - - return {payload, jwt}; -}; - // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { const browser = bowser._detect(req.headers['user-agent']); @@ -174,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); @@ -191,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; @@ -491,6 +486,5 @@ module.exports = { HandleAuthPopupCallback, HandleGenerateCredentials, HandleLogout, - GeneratePersonalAccessToken, CheckBlacklisted }; diff --git a/services/tokens.js b/services/tokens.js index eeefce175..3c78a0a94 100644 --- a/services/tokens.js +++ b/services/tokens.js @@ -1,5 +1,13 @@ +const errors = require('../errors'); const UserModel = require('../models/user'); -const {GeneratePersonalAccessToken} = require('./passport'); +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 @@ -16,7 +24,16 @@ module.exports = class TokenService { static async create(userID, tokenName) { // Create the token. - let {payload, jwt} = GeneratePersonalAccessToken(userID); + 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 = { @@ -63,6 +80,34 @@ module.exports = class TokenService { }); } + /** + * 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. * diff --git a/test/server/services/tokens.js b/test/server/services/tokens.js new file mode 100644 index 000000000..2aa1f291d --- /dev/null +++ b/test/server/services/tokens.js @@ -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); + }); + + }); +}); From 91b7cd10f87971e4a5fef0eefbca9d63def59506 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 21 Jun 2017 11:35:00 -0600 Subject: [PATCH 3/3] fixing all the :ghost: --- bin/cli-token | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/cli-token b/bin/cli-token index 50335d925..fa5d2e350 100755 --- a/bin/cli-token +++ b/bin/cli-token @@ -10,7 +10,7 @@ const TokensService = require('../services/tokens'); const util = require('./util'); const Table = require('cli-table'); -// Regeister the shutdown criteria. +// Register the shutdown criteria. util.onshutdown([ () => mongoose.disconnect() ]);