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 new file mode 100755 index 000000000..fa5d2e350 --- /dev/null +++ b/bin/cli-token @@ -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 ') + .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 09b50cb0e..22f4a668f 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! @@ -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 } 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 8be745f36..abfd16c6f 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', @@ -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 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 2bee02110..ed84f2265 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']); case types.SEARCH_COMMENT_STATUS_HISTORY: return check(user, ['ADMIN', 'MODERATOR']); default: diff --git a/services/passport.js b/services/passport.js index 1d031c39c..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'); @@ -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; diff --git a/services/tokens.js b/services/tokens.js new file mode 100644 index 000000000..3c78a0a94 --- /dev/null +++ b/services/tokens.js @@ -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; + } + +}; 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); + }); + + }); +});