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); + }); + + }); +});