mirror of
https://github.com/wassname/talk.git
synced 2026-07-05 21:24:47 +08:00
Merge branch 'master' into bug-killing-spree
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user