diff --git a/.gitignore b/.gitignore index 2cf001334..847f397e8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ verdaccio-*.tgz .DS_Store build/ +### Test + +test/unit/partials/store/test-jwt-storage/* + ### !bin/verdaccio test-storage* @@ -10,7 +14,6 @@ access-storage* .verdaccio_test_env node_modules package-lock.json -build/ npm_test-fails-add-tarball* yarn-error.log diff --git a/conf/default.yaml b/conf/default.yaml index cacde36b0..b3723f6a6 100644 --- a/conf/default.yaml +++ b/conf/default.yaml @@ -23,6 +23,16 @@ auth: # You can set this to -1 to disable registration. #max_users: 1000 +security: + api: + jwt: + sign: + expiresIn: 60d + notBefore: 1 + web: + sign: + expiresIn: 7d + # a list of other known repositories we can talk to uplinks: npmjs: diff --git a/conf/docker.yaml b/conf/docker.yaml index 4e75ef2b3..58bd434f2 100644 --- a/conf/docker.yaml +++ b/conf/docker.yaml @@ -27,6 +27,16 @@ auth: # You can set this to -1 to disable registration. #max_users: 1000 +security: + api: + jwt: + sign: + expiresIn: 60d + notBefore: 1 + web: + sign: + expiresIn: 7d + # a list of other known repositories we can talk to uplinks: npmjs: diff --git a/docs/config.md b/docs/config.md index 666e24559..218c5f9d1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -61,6 +61,31 @@ auth: max_users: 1000 ``` +### Security + +Since: `verdaccio@4.0.0` due [#168](https://github.com/verdaccio/verdaccio/pull/168) + +The security block allows you to customise the token signature. To enable [JWT (json web token)](https://jwt.io/) new signture you need to add the block `jwt` to `api` section, `web` uses by default `jwt`. + +The configuration is separated in two sections, `api` and `web`. To use JWT on `api`, it has to be defined, otherwise will use the legacy token signature (`aes192`). For JWT you might customize the [signature](https://github.com/auth0/node-jsonwebtoken#jwtsignpayload-secretorprivatekey-options-callback) and the token [verification](https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback) with your own properties. + +``` +security: + api: + legacy: true + jwt: + sign: + expiresIn: 29d + verify: + someProp: [value] + web: + sign: + expiresIn: 7d # 7 days by default + verify: + someProp: [value] +``` +> We highly recommend move to JWT since legacy signature (`aes192`) is deprecated and will disappear in future versions. + ### Web UI This properties allow you to modify the look and feel of the web UI. For more information about this section read the [web ui page](web.md). diff --git a/docs/uplinks.md b/docs/uplinks.md index c0a723bd6..dd3118542 100644 --- a/docs/uplinks.md +++ b/docs/uplinks.md @@ -79,7 +79,6 @@ uplinks: ### You Must know -* Verdaccio does not use Basic Authentication since version `v2.3.0`. All tokens generated by verdaccio are based on JWT ([JSON Web Token](https://jwt.io/)) * Uplinks must be registries compatible with the `npm` endpoints. Eg: *verdaccio*, `sinopia@1.4.0`, *npmjs registry*, *yarn registry*, *JFrog*, *Nexus* and more. * Setting `cache` to false will help to save space in your hard drive. This will avoid store `tarballs` but [it will keep metadata in folders](https://github.com/verdaccio/verdaccio/issues/391). * Exceed with multiple uplinks might slow down the lookup of your packages due for each request a npm client does, verdaccio does 1 call for each uplink. diff --git a/package.json b/package.json index 8abb0398a..f242ecfb9 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "express": "4.16.3", "global": "4.3.2", "handlebars": "4.0.11", - "http-errors": "1.6.3", + "http-errors": "1.7.0", "js-base64": "2.4.8", "js-string-escape": "1.0.1", "js-yaml": "3.12.0", @@ -54,7 +54,7 @@ "devDependencies": { "@commitlint/cli": "7.0.0", "@commitlint/config-conventional": "7.0.1", - "@verdaccio/types": "3.4.2", + "@verdaccio/types": "3.7.1", "babel-cli": "6.26.0", "babel-core": "6.26.3", "babel-eslint": "8.2.6", @@ -194,7 +194,8 @@ }, "lint-staged": { "*.yaml": [ - "prettier --parser yaml --no-config --single-quote --write" + "prettier --parser yaml --no-config --single-quote --write", + "git add" ], "*.js": [ "eslint .", diff --git a/src/api/endpoint/api/user.js b/src/api/endpoint/api/user.js index d68f67b23..3685029db 100644 --- a/src/api/endpoint/api/user.js +++ b/src/api/endpoint/api/user.js @@ -1,34 +1,40 @@ // @flow -import type {$Response, Router} from 'express'; -import type {$RequestExtend, $ResponseExtend, $NextFunctionVer, IAuth} from '../../../../types'; -import {ErrorCode} from '../../../lib/utils'; - import _ from 'lodash'; import Cookies from 'cookies'; -export default function(route: Router, auth: IAuth) { +import {ErrorCode} from '../../../lib/utils'; +import {API_MESSAGE, HTTP_STATUS} from '../../../lib/constants'; +import {createSessionToken, getApiToken, getAuthenticatedMessage} from '../../../lib/auth-utils'; + +import type {Config} from '@verdaccio/types'; +import type {$Response, Router} from 'express'; +import type {$RequestExtend, $ResponseExtend, $NextFunctionVer, IAuth} from '../../../../types'; + +export default function(route: Router, auth: IAuth, config: Config) { route.get('/-/user/:org_couchdb_user', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { - res.status(200); + res.status(HTTP_STATUS.OK); next({ - ok: 'you are authenticated as "' + req.remote_user.name + '"', + ok: getAuthenticatedMessage(req.remote_user.name), }); }); - route.put('/-/user/:org_couchdb_user/:_rev?/:revision?', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { - let token = (req.body.name && req.body.password) - ? auth.aesEncrypt(new Buffer(req.body.name + ':' + req.body.password)).toString('base64') - : undefined; + route.put('/-/user/:org_couchdb_user/:_rev?/:revision?', async function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { + const {name, password} = req.body; + if (_.isNil(req.remote_user.name) === false) { - res.status(201); + const token = (name && password) ? await getApiToken(auth, config, req.remote_user, password) : undefined; + + res.status(HTTP_STATUS.CREATED); + return next({ - ok: 'you are authenticated as \'' + req.remote_user.name + '\'', + ok: getAuthenticatedMessage(req.remote_user.name), token, }); } else { - auth.add_user(req.body.name, req.body.password, function(err, user) { + auth.add_user(name, password, async function(err, user) { if (err) { - if (err.status >= 400 && err.status < 500) { + if (err.status >= HTTP_STATUS.BAD_REQUEST && err.status < HTTP_STATUS.INTERNAL_ERROR) { // With npm registering is the same as logging in, // and npm accepts only an 409 error. // So, changing status code here. @@ -37,20 +43,22 @@ export default function(route: Router, auth: IAuth) { return next(err); } + const token = (name && password) ? await getApiToken(auth, config, user, password) : undefined; + req.remote_user = user; - res.status(201); + res.status(HTTP_STATUS.CREATED); return next({ - ok: 'user \'' + req.body.name + '\' created', - token: token, + ok: `user '${req.body.name }' created`, + token, }); }); } }); route.delete('/-/user/token/*', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) { - res.status(200); + res.status(HTTP_STATUS.OK); next({ - ok: 'Logged out', + ok: API_MESSAGE.LOGGED_OUT, }); }); @@ -58,10 +66,8 @@ export default function(route: Router, auth: IAuth) { // placeholder 'cause npm require to be authenticated to publish // we do not do any real authentication yet route.post('/_session', Cookies.express(), function(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { - res.cookies.set('AuthSession', String(Math.random()), { - // npmjs.org sets 10h expire - expires: new Date(Date.now() + 10 * 60 * 60 * 1000), - }); + res.cookies.set('AuthSession', String(Math.random()), createSessionToken()); + next({ ok: true, name: 'somebody', diff --git a/src/api/endpoint/index.js b/src/api/endpoint/index.js index 2275130e9..b489acec4 100644 --- a/src/api/endpoint/index.js +++ b/src/api/endpoint/index.js @@ -46,7 +46,7 @@ export default function(config: Config, auth: IAuth, storage: IStorageHandler) { whoami(app); pkg(app, auth, storage, config); search(app, auth, storage); - user(app, auth); + user(app, auth, config); distTags(app, auth, storage); publish(app, auth, storage, config); ping(app); diff --git a/src/api/index.js b/src/api/index.js index f1d95b2d4..ab356484b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -12,6 +12,8 @@ import apiEndpoint from './endpoint'; import {ErrorCode} from '../lib/utils'; import {API_ERROR, HTTP_STATUS} from '../lib/constants'; import AppConfig from '../lib/config'; +import webAPI from './web/api'; +import web from './web'; import type {$Application} from 'express'; import type { @@ -74,8 +76,8 @@ const defineAPI = function(config: IConfig, storage: IStorageHandler) { // For WebUI & WebUI API if (_.get(config, 'web.enable', true)) { - app.use('/', require('./web')(config, auth, storage)); - app.use('/-/verdaccio/', require('./web/api')(config, auth, storage)); + app.use('/', web(config, auth, storage)); + app.use('/-/verdaccio/', webAPI(config, auth, storage)); } else { app.get('/', function(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { next(ErrorCode.getNotFound(API_ERROR.WEB_DISABLED)); diff --git a/src/api/web/api.js b/src/api/web/api.js index c04255261..e447b8fff 100644 --- a/src/api/web/api.js +++ b/src/api/web/api.js @@ -16,7 +16,7 @@ const route = Router(); /* eslint new-cap: 0 */ /* This file include all verdaccio only API(Web UI), for npm API please see ../endpoint/ */ -module.exports = function(config: Config, auth: IAuth, storage: IStorageHandler) { +export default function(config: Config, auth: IAuth, storage: IStorageHandler) { Search.configureStorage(storage); // validate all of these params as a package name @@ -43,4 +43,4 @@ module.exports = function(config: Config, auth: IAuth, storage: IStorageHandler) // We will/may replace current token with JWT in next major release, and it will not expire at all(configurable). return route; -}; +} diff --git a/src/api/web/endpoint/user.js b/src/api/web/endpoint/user.js index 8186de869..ac3f5f6a8 100644 --- a/src/api/web/endpoint/user.js +++ b/src/api/web/endpoint/user.js @@ -1,22 +1,29 @@ // @flow -import HTTPError from 'http-errors'; -import type {Config} from '@verdaccio/types'; +import {HTTP_STATUS} from '../../../lib/constants'; + import type {Router} from 'express'; +import type {Config, RemoteUser, JWTSignOptions} from '@verdaccio/types'; import type {IAuth, $ResponseExtend, $RequestExtend, $NextFunctionVer} from '../../../../types'; +import {ErrorCode} from '../../../lib/utils'; +import {getSecurity} from '../../../lib/auth-utils'; function addUserAuthApi(route: Router, auth: IAuth, config: Config) { route.post('/login', function(req: $RequestExtend, res: $ResponseExtend, next: $NextFunctionVer) { - auth.authenticate(req.body.username, req.body.password, (err, user) => { - if (!err) { + const {username, password} = req.body; + + auth.authenticate(username, password, async (err, user: RemoteUser) => { + if (err) { + const errorCode = err.message ? HTTP_STATUS.UNAUTHORIZED : HTTP_STATUS.INTERNAL_ERROR; + next(ErrorCode.getCode(errorCode, err.message)); + } else { req.remote_user = user; + const jWTSignOptions: JWTSignOptions = getSecurity(config).web.sign; next({ - token: auth.issueUIjwt(user, '24h'), + token: await auth.jwtEncrypt(user, jWTSignOptions), username: req.remote_user.name, }); - } else { - next(HTTPError[err.message ? 401 : 500](err.message)); } }); }); diff --git a/src/api/web/index.js b/src/api/web/index.js index 1124ecb56..0bb4f887e 100644 --- a/src/api/web/index.js +++ b/src/api/web/index.js @@ -3,11 +3,10 @@ import _ from 'lodash'; import fs from 'fs'; import Search from '../../lib/search'; import * as Utils from '../../lib/utils'; -import {WEB_TITLE} from '../../lib/constants'; +import {HTTP_STATUS, WEB_TITLE} from '../../lib/constants'; const {securityIframe} = require('../middleware'); /* eslint new-cap:off */ -const router = express.Router(); const env = require('../../config/env'); const template = fs.readFileSync(`${env.DIST_PATH}/index.html`).toString(); const spliceURL = require('../../utils/string').spliceURL; @@ -15,6 +14,8 @@ const spliceURL = require('../../utils/string').spliceURL; module.exports = function(config, auth, storage) { Search.configureStorage(storage); + const router = express.Router(); + router.use(auth.webUIJWTmiddleware()); router.use(securityIframe); @@ -25,7 +26,7 @@ module.exports = function(config, auth, storage) { if (!err) { return; } - if (err.status === 404) { + if (err.status === HTTP_STATUS.NOT_FOUND) { next(); } else { next(err); diff --git a/src/lib/auth-utils.js b/src/lib/auth-utils.js index aa3586cf1..4e467cdfc 100644 --- a/src/lib/auth-utils.js +++ b/src/lib/auth-utils.js @@ -1,9 +1,60 @@ // @flow +import _ from 'lodash'; +import {convertPayloadToBase64, ErrorCode} from './utils'; +import {API_ERROR, HTTP_STATUS, ROLES, TIME_EXPIRATION_7D, TOKEN_BASIC, TOKEN_BEARER} from './constants'; -import {ErrorCode} from './utils'; -import {API_ERROR} from './constants'; +import type { + RemoteUser, + Package, + Callback, + Config, + Security, + APITokenOptions, + JWTOptions} from '@verdaccio/types'; +import type { + CookieSessionToken, IAuthWebUI, AuthMiddlewarePayload, AuthTokenHeader, BasicPayload, +} from '../../types'; +import {aesDecrypt, verifyPayload} from './crypto-utils'; -import type {RemoteUser, Package, Callback} from '@verdaccio/types'; + +/** + * Create a RemoteUser object + * @return {Object} { name: xx, pluginGroups: [], real_groups: [] } + */ +export function createRemoteUser(name: string, pluginGroups: Array): RemoteUser { + const isGroupValid: boolean = _.isArray(pluginGroups); + const groups = (isGroupValid ? pluginGroups : []).concat([ + ROLES.$ALL, + ROLES.$AUTH, + ROLES.DEPRECATED_ALL, + ROLES.DEPRECATED_AUTH, + ROLES.ALL]); + + return { + name, + groups, + real_groups: pluginGroups, + }; +} + + +/** + * Builds an anonymous remote user in case none is logged in. + * @return {Object} { name: xx, groups: [], real_groups: [] } + */ +export function createAnonymousRemoteUser(): RemoteUser { + return { + name: undefined, + // groups without '$' are going to be deprecated eventually + groups: [ + ROLES.$ALL, + ROLES.$ANONYMOUS, + ROLES.DEPRECATED_ALL, + ROLES.DEPRECATED_ANONUMOUS, + ], + real_groups: [], + }; +} export function allow_action(action: string) { return function(user: RemoteUser, pkg: Package, callback: Callback) { @@ -36,3 +87,162 @@ export function getDefaultPlugins() { allow_publish: allow_action('publish'), }; } + +export function createSessionToken(): CookieSessionToken { + const tenHoursTime = 10 * 60 * 60 * 1000; + + return { + // npmjs.org sets 10h expire + expires: new Date(Date.now() + tenHoursTime), + }; +} + +const defaultWebTokenOptions: JWTOptions = { + sign: { + expiresIn: TIME_EXPIRATION_7D, + }, + verify: {}, +}; + +const defaultApiTokenConf: APITokenOptions = { + legacy: true, + sign: {}, +}; + +export function getSecurity(config: Config): Security { + const defaultSecurity: Security = { + web: defaultWebTokenOptions, + api: defaultApiTokenConf, + }; + + if (_.isNil(config.security) === false) { + return _.merge(defaultSecurity, config.security); + } + + return defaultSecurity; +} + +export function getAuthenticatedMessage(user: string): string { + return `you are authenticated as '${user}'`; +} + +export function buildUserBuffer(name: string, password: string) { + return Buffer.from(`${name}:${password}`, 'utf8'); +} + +export function isAESLegacy(security: Security): boolean { + const {legacy, jwt} = security.api; + + return _.isNil(legacy) === false &&_.isNil(jwt) && legacy === true; +} + +export async function getApiToken( + auth: IAuthWebUI, + config: Config, + remoteUser: RemoteUser, + aesPassword: string): Promise { + const security: Security = getSecurity(config); + + if (isAESLegacy(security)) { + // fallback all goes to AES encryption + return await new Promise((resolve) => { + resolve(auth.aesEncrypt(buildUserBuffer((remoteUser: any).name, aesPassword)).toString('base64')); + }); + } else { + // i am wiling to use here _.isNil but flow does not like it yet. + const {jwt} = security.api; + + if (typeof jwt !== 'undefined' && + typeof jwt.sign !== 'undefined') { + return await auth.jwtEncrypt(remoteUser, jwt.sign); + } else { + return await new Promise((resolve) => { + resolve(auth.aesEncrypt(buildUserBuffer((remoteUser: any).name, aesPassword)).toString('base64')); + }); + } + } +} + +export function parseAuthTokenHeader(authorizationHeader: string): AuthTokenHeader { + const parts = authorizationHeader.split(' '); + const [scheme, token] = parts; + + return {scheme, token}; +} + +export function parseBasicPayload(credentials: string): BasicPayload { + const index = credentials.indexOf(':'); + if (index < 0) { + return; + } + + const user: string = credentials.slice(0, index); + const password: string = credentials.slice(index + 1); + + return {user, password}; +} + +export function parseAESCredentials( + authorizationHeader: string, secret: string) { + const {scheme, token} = parseAuthTokenHeader(authorizationHeader); + + // basic is deprecated and should not be enforced + if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) { + const credentials = convertPayloadToBase64(token).toString(); + + return credentials; + } else if (scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) { + const tokenAsBuffer = convertPayloadToBase64(token); + const credentials = aesDecrypt(tokenAsBuffer, secret).toString('utf8'); + + return credentials; + } +} + +export function verifyJWTPayload(token: string, secret: string): RemoteUser { + try { + const payload: RemoteUser = (verifyPayload(token, secret): RemoteUser); + + return payload; + } catch (err) { + // #168 this check should be removed as soon AES encrypt is removed. + if (err.name === 'JsonWebTokenError') { + // it might be possible the jwt configuration is enabled and + // old tokens fails still remains in usage, thus + // we return an anonymous user to force log in. + return createAnonymousRemoteUser(); + } else { + throw ErrorCode.getCode(HTTP_STATUS.UNAUTHORIZED, err.message); + } + } +} + +export function isAuthHeaderValid(authorization: string): boolean { + return authorization.split(' ').length === 2; +} + +export function getMiddlewareCredentials( + security: Security, + secret: string, + authorizationHeader: string + ): AuthMiddlewarePayload { + if (isAESLegacy(security)) { + const credentials = parseAESCredentials(authorizationHeader, secret); + if (!credentials) { + return; + } + + const parsedCredentials = parseBasicPayload(credentials); + if (!parsedCredentials) { + return; + } + + return parsedCredentials; + } else { + const {scheme, token} = parseAuthTokenHeader(authorizationHeader); + + if (_.isString(token) && scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) { + return verifyJWTPayload(token, secret); + } + } +} diff --git a/src/lib/auth.js b/src/lib/auth.js index 9fc97a815..0d6cb1e7d 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -2,19 +2,26 @@ import _ from 'lodash'; -import {API_ERROR, HTTP_STATUS, ROLES, TOKEN_BASIC, TOKEN_BEARER} from './constants'; +import {API_ERROR, TOKEN_BASIC, TOKEN_BEARER} from './constants'; import loadPlugin from '../lib/plugin-loader'; -import {buildBase64Buffer, ErrorCode} from './utils'; -import {aesDecrypt, aesEncrypt, signPayload, verifyPayload} from './crypto-utils'; -import {getDefaultPlugins} from './auth-utils'; - +import {aesEncrypt, signPayload} from './crypto-utils'; +import { + getDefaultPlugins, + getMiddlewareCredentials, + verifyJWTPayload, + createAnonymousRemoteUser, + isAuthHeaderValid, + getSecurity, + isAESLegacy, parseAuthTokenHeader, parseBasicPayload, createRemoteUser, +} from './auth-utils'; +import {convertPayloadToBase64, ErrorCode} from './utils'; import {getMatchedPackagesSpec} from './config-utils'; -import type {Config, Logger, Callback, IPluginAuth, RemoteUser} from '@verdaccio/types'; +import type { + Config, Logger, Callback, IPluginAuth, RemoteUser, JWTSignOptions, Security, +} from '@verdaccio/types'; import type {$Response, NextFunction} from 'express'; -import type {$RequestExtend, JWTPayload} from '../../types'; -import type {IAuth} from '../../types'; - +import type {$RequestExtend, IAuth} from '../../types'; const LoggerApi = require('./logger'); @@ -23,7 +30,6 @@ class Auth implements IAuth { logger: Logger; secret: string; plugins: Array; - static DEFAULT_EXPIRE_WEB_TOKEN: string = '7d'; constructor(config: Config) { this.config = config; @@ -50,8 +56,9 @@ class Auth implements IAuth { this.plugins.push(getDefaultPlugins()); } - authenticate(user: string, password: string, cb: Callback) { + authenticate(username: string, password: string, cb: Callback) { const plugins = this.plugins.slice(0); + const self = this; (function next() { const plugin = plugins.shift(); @@ -59,7 +66,8 @@ class Auth implements IAuth { return next(); } - plugin.authenticate(user, password, function(err, groups) { + self.logger.trace( {username}, 'authenticating @{username}'); + plugin.authenticate(username, password, function(err, groups) { if (err) { return cb(err); } @@ -74,14 +82,14 @@ class Auth implements IAuth { if (!!groups && groups.length !== 0) { // TODO: create a better understanding of expectations if (_.isString(groups)) { - throw new TypeError('invalid type for function'); + throw new TypeError('plugin group error: invalid type for function'); } const isGroupValid: boolean = _.isArray(groups); if (!isGroupValid) { throw new TypeError(API_ERROR.BAD_FORMAT_USER_GROUP); } - return cb(err, authenticatedUser(user, groups)); + return cb(err, createRemoteUser(username, groups)); } next(); }); @@ -91,6 +99,7 @@ class Auth implements IAuth { add_user(user: string, password: string, cb: Callback) { let self = this; let plugins = this.plugins.slice(0); + this.logger.trace( {user}, 'add user @{user}'); (function next() { let plugin = plugins.shift(); @@ -122,6 +131,7 @@ class Auth implements IAuth { let plugins = this.plugins.slice(0); // $FlowFixMe let pkg = Object.assign({name: packageName}, getMatchedPackagesSpec(packageName, this.config.packages)); + this.logger.trace( {packageName}, 'allow access for @{packageName}'); (function next() { const plugin = plugins.shift(); @@ -151,6 +161,7 @@ class Auth implements IAuth { let plugins = this.plugins.slice(0); // $FlowFixMe let pkg = Object.assign({name: packageName}, getMatchedPackagesSpec(packageName, this.config.packages)); + this.logger.trace( {packageName}, 'allow publish for @{packageName}'); (function next() { const plugin = plugins.shift(); @@ -188,62 +199,96 @@ class Auth implements IAuth { return _next(); }; - if (_.isUndefined(req.remote_user) === false - && _.isUndefined(req.remote_user.name) === false) { + if (this._isRemoteUserMissing(req.remote_user)) { return next(); } - req.remote_user = buildAnonymousUser(); - const authorization = req.headers.authorization; + // in case auth header does not exist we return anonymous function + req.remote_user = createAnonymousRemoteUser(); + + const {authorization} = req.headers; if (_.isNil(authorization)) { return next(); } - const parts = authorization.split(' '); - if (parts.length !== 2) { + if (!isAuthHeaderValid(authorization)) { + this.logger.trace('api middleware auth heather is not valid'); return next( ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER) ); } - const credentials = this._parseCredentials(parts); - if (!credentials) { - return next(); + const security: Security = getSecurity(this.config); + const {secret} = this.config; + + if (isAESLegacy(security)) { + this.logger.trace('api middleware using legacy auth token'); + this._handleAESMiddleware(req, security, secret, authorization, next); + } else { + this.logger.trace('api middleware using JWT auth token'); + this._handleJWTAPIMiddleware(req, security, secret, authorization, next); } + }; + } - const index = credentials.indexOf(':'); - if (index < 0) { - return next(); - } - - const user: string = credentials.slice(0, index); - const pass: string = credentials.slice(index + 1); - - this.authenticate(user, pass, function(err, user) { + _handleJWTAPIMiddleware( + req: $RequestExtend, + security: Security, + secret: string, + authorization: string, + next: Function) { + const {scheme, token} = parseAuthTokenHeader(authorization); + if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) { + // this should happen when client tries to login with an existing user + const credentials = convertPayloadToBase64(token).toString(); + const {user, password} = (parseBasicPayload(credentials): any); + this.authenticate(user, password, (err, user) => { if (!err) { req.remote_user = user; next(); } else { - req.remote_user = buildAnonymousUser(); + req.remote_user = createAnonymousRemoteUser(); next(err); } }); - }; + } else { + // jwt handler + const credentials: any = getMiddlewareCredentials(security, secret, authorization); + if (credentials) { + // if the signature is valid we rely on it + req.remote_user = credentials; + next(); + } else { + // with JWT throw 401 + next(ErrorCode.getForbidden(API_ERROR.BAD_USERNAME_PASSWORD)); + } + } } - _parseCredentials(parts: Array) { - let credentials; - const scheme = parts[0]; - if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) { - credentials = buildBase64Buffer(parts[1]).toString(); - this.logger.info(API_ERROR.DEPRECATED_BASIC_HEADER); - return credentials; - } else if (scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) { - const token = buildBase64Buffer(parts[1]); + _handleAESMiddleware(req: $RequestExtend, + security: Security, + secret: string, + authorization: string, + next: Function) { + const credentials: any = getMiddlewareCredentials(security, secret, authorization); + if (credentials) { + const {user, password} = credentials; + this.authenticate(user, password, (err, user) => { + if (!err) { + req.remote_user = user; + next(); + } else { + req.remote_user = createAnonymousRemoteUser(); + next(err); + } + }); + } else { + // we force npm client to ask again with basic authentication + return next(ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER)); + } + } - credentials = aesDecrypt(token, this.secret).toString('utf8'); - return credentials; - } else { - return; - } + _isRemoteUserMissing(remote_user: RemoteUser): boolean { + return _.isUndefined(remote_user) === false && + (_.isUndefined(remote_user.name) === false); } /** @@ -251,62 +296,65 @@ class Auth implements IAuth { */ webUIJWTmiddleware() { return (req: $RequestExtend, res: $Response, _next: NextFunction) => { - if (_.isNull(req.remote_user) === false && _.isNil(req.remote_user.name) === false) { + if (this._isRemoteUserMissing(req.remote_user)) { return _next(); } req.pause(); - const next = () => { + const next = (err) => { req.resume(); + if (err) { + // req.remote_user.error = err.message; + res.status(err.statusCode).send(err.message); + } + return _next(); }; - const token = (req.headers.authorization || '').replace(`${TOKEN_BEARER} `, ''); + const {authorization} = req.headers; + if (_.isNil(authorization)) { + return next(); + } + + if (!isAuthHeaderValid(authorization)) { + return next( ErrorCode.getBadRequest(API_ERROR.BAD_AUTH_HEADER) ); + } + + const token = (authorization || '').replace(`${TOKEN_BEARER} `, ''); if (!token) { return next(); } - let decoded; + let credentials; try { - decoded = this.decode_token(token); + credentials = verifyJWTPayload(token, this.config.secret); } catch (err) { // FIXME: intended behaviour, do we want it? } - if (decoded) { - req.remote_user = authenticatedUser(decoded.user, decoded.group); + if (credentials) { + const {name, groups} = credentials; + // $FlowFixMe + req.remote_user = createRemoteUser(name, groups); } else { - req.remote_user = buildAnonymousUser(); + req.remote_user = createAnonymousRemoteUser(); } next(); }; } - issueUIjwt(user: any, expiresIn: string) { - const {name, real_groups} = user; - const payload: JWTPayload = { - user: name, + async jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): string { + const {real_groups} = user; + const payload: RemoteUser = { + ...user, group: real_groups && real_groups.length ? real_groups : undefined, }; - return signPayload(payload, this.secret, {expiresIn: expiresIn || Auth.DEFAULT_EXPIRE_WEB_TOKEN}); - } + const token: string = await signPayload(payload, this.secret, signOptions); - /** - * Decodes the token. - * @param {*} token - * @return {Object} - */ - decode_token(token: string) { - let decoded; - try { - decoded = verifyPayload(token, this.secret); - } catch (err) { - throw ErrorCode.getCode(HTTP_STATUS.UNAUTHORIZED, err.message); - } - - return decoded; + // $FlowFixMe + return token; } /** @@ -317,37 +365,4 @@ class Auth implements IAuth { } } -/** - * Builds an anonymous user in case none is logged in. - * @return {Object} { name: xx, groups: [], real_groups: [] } - */ -function buildAnonymousUser() { - return { - name: undefined, - // groups without '$' are going to be deprecated eventually - groups: [ROLES.$ALL, ROLES.$ANONYMOUS, ROLES.DEPRECATED_ALL, ROLES.DEPRECATED_ANONUMOUS], - real_groups: [], - }; -} - -/** - * Authenticate an user. - * @return {Object} { name: xx, pluginGroups: [], real_groups: [] } - */ -function authenticatedUser(name: string, pluginGroups: Array) { - const isGroupValid: boolean = _.isArray(pluginGroups); - const groups = (isGroupValid ? pluginGroups : []).concat([ - ROLES.$ALL, - ROLES.$AUTH, - ROLES.DEPRECATED_ALL, - ROLES.DEPRECATED_AUTH, - ROLES.ALL]); - - return { - name, - groups, - real_groups: pluginGroups, - }; -} - export default Auth; diff --git a/src/lib/config.js b/src/lib/config.js index a01ea96f1..f5a8840e3 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -15,6 +15,7 @@ import {APP_ERROR} from './constants'; import type { PackageList, Config as AppConfig, + Security, Logger, } from '@verdaccio/types'; @@ -38,6 +39,7 @@ class Config implements AppConfig { self_path: string; storage: string | void; plugins: string | void; + security: Security; $key: any; $value: any; diff --git a/src/lib/constants.js b/src/lib/constants.js index a6bd09b3e..6a3ce3389 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -1,7 +1,9 @@ // @flow -export const DEFAULT_PORT = '4873'; -export const DEFAULT_DOMAIN = 'localhost'; +export const DEFAULT_PORT: string = '4873'; +export const DEFAULT_DOMAIN: string = 'localhost'; +export const TIME_EXPIRATION_24H: string ='24h'; +export const TIME_EXPIRATION_7D: string = '7d'; export const HEADERS = { JSON: 'application/json', @@ -63,6 +65,7 @@ export const API_MESSAGE = { TAG_UPDATED: 'tags updated', TAG_REMOVED: 'tag removed', TAG_ADDED: 'package tagged', + LOGGED_OUT: 'Logged out', }; export const API_ERROR = { diff --git a/src/lib/crypto-utils.js b/src/lib/crypto-utils.js index 8c4c376ad..c0df6a1d3 100644 --- a/src/lib/crypto-utils.js +++ b/src/lib/crypto-utils.js @@ -1,12 +1,21 @@ // @flow -import {createDecipher, createCipher, createHash, pseudoRandomBytes} from 'crypto'; +import { + createDecipher, + createCipher, + createHash, + pseudoRandomBytes, +} from 'crypto'; import jwt from 'jsonwebtoken'; -import type {JWTPayload, JWTSignOptions} from '../../types'; + +import type {JWTSignOptions, RemoteUser} from '@verdaccio/types'; export const defaultAlgorithm = 'aes192'; +export const defaultTarballHashAlgorithm = 'sha1'; export function aesEncrypt(buf: Buffer, secret: string): Buffer { + // deprecated + // https://nodejs.org/api/crypto.html#crypto_crypto_createcipher_algorithm_password_options const c = createCipher(defaultAlgorithm, secret); const b1 = c.update(buf); const b2 = c.final(); @@ -16,6 +25,8 @@ export function aesEncrypt(buf: Buffer, secret: string): Buffer { export function aesDecrypt(buf: Buffer, secret: string) { try { + // deprecated + // https://nodejs.org/api/crypto.html#crypto_crypto_createdecipher_algorithm_password_options const c = createDecipher(defaultAlgorithm, secret); const b1 = c.update(buf); const b2 = c.final(); @@ -26,7 +37,7 @@ export function aesDecrypt(buf: Buffer, secret: string) { } export function createTarballHash() { - return createHash('sha1'); + return createHash(defaultTarballHashAlgorithm); } /** @@ -44,13 +55,18 @@ export function generateRandomHexString(length: number = 8) { return pseudoRandomBytes(length).toString('hex'); } -export function signPayload(payload: JWTPayload, secret: string, options: JWTSignOptions) { - return jwt.sign(payload, secret, { - notBefore: '1000', // Make sure the time will not rollback :) - ...options, +export async function signPayload( + payload: RemoteUser, + secretOrPrivateKey: string, + options: JWTSignOptions): Promise { + return new Promise(function(resolve, reject) { + return jwt.sign(payload, secretOrPrivateKey, { + notBefore: '1000', // Make sure the time will not rollback :) + ...options, + }, (error, token) => error ? reject(error) : resolve(token)); }); } -export function verifyPayload(token: string, secret: string) { - return jwt.verify(token, secret); +export function verifyPayload(token: string, secretOrPrivateKey: string) { + return jwt.verify(token, secretOrPrivateKey); } diff --git a/src/lib/utils.js b/src/lib/utils.js index 6c4da641d..2af8d30a4 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -34,7 +34,7 @@ export function getUserAgent(): string { return `${pkgName}/${pkgVersion}`; } -export function buildBase64Buffer(payload: string): Buffer { +export function convertPayloadToBase64(payload: string): Buffer { return new Buffer(payload, 'base64'); } diff --git a/src/webui/utils/api.js b/src/webui/utils/api.js index fa0436ca4..a48067bde 100644 --- a/src/webui/utils/api.js +++ b/src/webui/utils/api.js @@ -10,7 +10,7 @@ class API { if (token) { if (!options.headers) options.headers = {}; - options.headers.authorization = token; + options.headers.authorization = `Bearer ${token}`; } if (!['http://', 'https://', '//'].some((prefix) => url.startsWith(prefix))) { diff --git a/test/functional/uplinks/cache.js b/test/functional/uplinks/cache.js index 40600e060..cf36e7f14 100644 --- a/test/functional/uplinks/cache.js +++ b/test/functional/uplinks/cache.js @@ -1,10 +1,10 @@ import fs from 'fs'; import path from 'path'; import assert from 'assert'; -import crypto from 'crypto'; import {readFile} from '../lib/test.utils'; import {HTTP_STATUS} from "../../../src/lib/constants"; import {TARBALL} from '../config.functional'; +import {createTarballHash} from '../../../src/lib/crypto-utils'; function getBinary() { return readFile('../fixtures/binary'); @@ -35,7 +35,7 @@ export default function (server, server2, server3) { beforeAll(function () { const pkg = require('../fixtures/package')(PKG_GH131); - pkg.dist.shasum = crypto.createHash('sha1').update(getBinary()).digest('hex'); + pkg.dist.shasum = createTarballHash().update(getBinary()).digest('hex'); return server.putVersion(PKG_GH131, '0.0.1', pkg) .status(HTTP_STATUS.CREATED) @@ -67,7 +67,7 @@ export default function (server, server2, server3) { beforeAll(function () { const pkg = require('../fixtures/package')(PKG_GH1312); - pkg.dist.shasum = crypto.createHash('sha1').update(getBinary()).digest('hex'); + pkg.dist.shasum = createTarballHash().update(getBinary()).digest('hex'); return server2.putVersion(PKG_GH1312, '0.0.1', pkg) .status(HTTP_STATUS.CREATED) diff --git a/test/lib/request.js b/test/lib/request.js index 5cbfb8bc3..41e81a0e9 100644 --- a/test/lib/request.js +++ b/test/lib/request.js @@ -116,7 +116,6 @@ function smartRequest(options: any): Promise { // store the response on symbol smartObject[requestData].response = res; - // console.log("======>smartRequest RESPONSE: ", body); resolve(body); }); }); diff --git a/test/lib/server_process.js b/test/lib/server_process.js index 5026f1562..7b7b8122d 100644 --- a/test/lib/server_process.js +++ b/test/lib/server_process.js @@ -68,25 +68,14 @@ export default class VerdaccioProcess implements IServerProcess { this.bridge.auth(CREDENTIALS.user, CREDENTIALS.password) .status(HTTP_STATUS.CREATED) .body_ok(new RegExp(CREDENTIALS.user)) - .then(() => { - resolve([this, body.pid]); - }, reject) + .then(() => resolve([this, body.pid]), reject) }, reject); } }); - this.childFork.on('error', (err) => { - console.log('error process', err); - reject([err, this]); - }); - - this.childFork.on('disconnect', (err) => { - reject([err, this]); - }); - - this.childFork.on('exit', (err) => { - reject([err, this]); - }); + this.childFork.on('error', (err) => reject([err, this])); + this.childFork.on('disconnect', (err) => reject([err, this])); + this.childFork.on('exit', (err) => reject([err, this])); } stop(): void { diff --git a/test/unit/__helper.js b/test/unit/__helper.js new file mode 100644 index 000000000..95b90b8c7 --- /dev/null +++ b/test/unit/__helper.js @@ -0,0 +1,5 @@ +import path from 'path'; + +export const parseConfigurationFile = (name) => { + return path.join(__dirname, `./partials/config/yaml/${name}.yaml`); +}; diff --git a/test/unit/api/__api-helper.js b/test/unit/api/__api-helper.js new file mode 100644 index 000000000..d6af9f8fa --- /dev/null +++ b/test/unit/api/__api-helper.js @@ -0,0 +1,32 @@ +// @flow + +import {HEADER_TYPE, HEADERS, HTTP_STATUS} from '../../../src/lib/constants'; + +export function getPackage( + request: any, + header: string, + pkg: string, + statusCode: number = HTTP_STATUS.OK) { + return new Promise((resolve) => { + request.get(`/${pkg}`) + .set('authorization', header) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(statusCode) + .end(function(err, res) { + resolve([err, res]); + }); + }); +} + +export function addUser(request: any, user: string, credentials: any, + statusCode: number = HTTP_STATUS.CREATED) { + return new Promise((resolve, reject) => { + request.put(`/-/user/org.couchdb.user:${user}`) + .send(credentials) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(statusCode) + .end(function(err, res) { + return resolve([err, res]); + }); + }); +} diff --git a/test/unit/api/api.jwt.spec.js b/test/unit/api/api.jwt.spec.js new file mode 100644 index 000000000..cdd3c76a2 --- /dev/null +++ b/test/unit/api/api.jwt.spec.js @@ -0,0 +1,115 @@ +// @flow + +import request from 'supertest'; +import _ from 'lodash'; +import path from 'path'; +import rimraf from 'rimraf'; + +import Config from '../../../src/lib/config'; +import endPointAPI from '../../../src/api/index'; + +import {HEADERS, HTTP_STATUS, HEADER_TYPE} from '../../../src/lib/constants'; +import {mockServer} from './mock'; +import {DOMAIN_SERVERS} from '../../functional/config.functional'; +import {parseConfigFile} from '../../../src/lib/utils'; +import {parseConfigurationFile} from '../__helper'; +import {addUser, getPackage} from './__api-helper'; +import {setup} from '../../../src/lib/logger'; +import {buildUserBuffer} from '../../../src/lib/auth-utils'; + +setup([]); +const credentials = { name: 'JotaJWT', password: 'secretPass' }; + +const parseConfigurationJWTFile = () => { + return parseConfigurationFile(`api-jwt/jwt`); +}; + +const FORBIDDEN_VUE: string = 'unregistered users are not allowed to access package vue'; + +describe('endpoint user auth JWT unit test', () => { + let config; + let app; + let mockRegistry; + + beforeAll(function(done) { + const store = path.join(__dirname, '../partials/store/test-jwt-storage'); + const mockServerPort = 55546; + rimraf(store, async () => { + const confS = parseConfigFile(parseConfigurationJWTFile()); + const configForTest = _.clone(confS); + configForTest.storage = store; + configForTest.auth = { + htpasswd: { + file: './test-jwt-storage/.htpasswd' + } + }; + configForTest.uplinks = { + npmjs: { + url: `http://${DOMAIN_SERVERS}:${mockServerPort}` + } + }; + configForTest.self_path = store; + config = new Config(configForTest); + app = await endPointAPI(config); + mockRegistry = await mockServer(mockServerPort).init(); + done(); + }); + }); + + afterAll(function(done) { + mockRegistry[0].stop(); + done(); + }); + + test('should test add a new user with JWT enabled', async (done) => { + const [err, res] = await addUser(request(app), credentials.name, credentials); + expect(err).toBeNull(); + expect(res.body.ok).toBeDefined(); + expect(res.body.token).toBeDefined(); + const token = res.body.token; + expect(typeof token).toBe('string'); + expect(res.body.ok).toMatch(`user '${credentials.name}' created`); + // testing JWT auth headers with token + // we need it here, because token is required + const [err1, resp1] = await getPackage(request(app), `Bearer ${token}`, 'vue'); + expect(err1).toBeNull(); + expect(resp1.body).toBeDefined(); + expect(resp1.body.name).toMatch('vue'); + + const [err2, resp2] = await getPackage(request(app), `Bearer fake`, 'vue', HTTP_STATUS.FORBIDDEN); + expect(err2).toBeNull(); + expect(resp2.statusCode).toBe(HTTP_STATUS.FORBIDDEN); + expect(resp2.body.error).toMatch(FORBIDDEN_VUE); + done(); + }); + + test('should emulate npm login when user already exist', async (done) => { + const credentials = { name: 'jwtUser2', password: 'secretPass' }; + // creates an user + await addUser(request(app), credentials.name, credentials); + // it should fails conflict 409 + await addUser(request(app), credentials.name, credentials, HTTP_STATUS.CONFLICT); + // npm will try to sign in sending credentials via basic auth header + const token = buildUserBuffer(credentials.name, credentials.password).toString('base64'); + request(app).put(`/-/user/org.couchdb.user:${credentials.name}/-rev/undefined`) + .send(credentials) + .set('authorization', `Basic ${token}`) + .expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET) + .expect(HTTP_STATUS.CREATED) + .end(function(err, res) { + expect(err).toBeNull(); + expect(res.body.ok).toBeDefined(); + expect(res.body.token).toBeDefined(); + done(); + }); + }); + + test('should fails on try to access with corrupted token', async (done) => { + const [err2, resp2] = await getPackage(request(app), `Bearer fake`, 'vue', HTTP_STATUS.FORBIDDEN); + expect(err2).toBeNull(); + expect(resp2.statusCode).toBe(HTTP_STATUS.FORBIDDEN); + expect(resp2.body.error).toMatch(FORBIDDEN_VUE); + done(); + }); + +}); diff --git a/test/unit/api/auth-utils.spec.js b/test/unit/api/auth-utils.spec.js new file mode 100644 index 000000000..934adb5c9 --- /dev/null +++ b/test/unit/api/auth-utils.spec.js @@ -0,0 +1,249 @@ +// @flow + +import _ from 'lodash'; +import Auth from '../../../src/lib/auth'; +// $FlowFixMe +import configExample from '../partials/config/index'; +import AppConfig from '../../../src/lib/config'; +import {setup} from '../../../src/lib/logger'; + +import {convertPayloadToBase64, parseConfigFile} from '../../../src/lib/utils'; +import { + buildUserBuffer, + getApiToken, + getAuthenticatedMessage, + getMiddlewareCredentials, + getSecurity +} from '../../../src/lib/auth-utils'; +import {aesDecrypt, verifyPayload} from '../../../src/lib/crypto-utils'; +import {parseConfigurationFile} from '../__helper'; + +import type {IAuth, } from '../../../types/index'; +import type {Config, Security, RemoteUser} from '@verdaccio/types'; + +setup(configExample.logs); + +describe('Auth utilities', () => { + const parseConfigurationSecurityFile = (name) => { + return parseConfigurationFile(`security/${name}`); + }; + + function getConfig(configFileName: string, secret: string) { + const conf = parseConfigFile(parseConfigurationSecurityFile(configFileName)); + const secConf= _.merge(configExample, conf); + secConf.secret = secret; + const config: Config = new AppConfig(secConf); + + return config; + } + + async function signCredentials( + configFileName: string, + username: string, + password: string, + secret = '12345', + methodToSpy: string, + methodNotBeenCalled: string): Promise { + const config: Config = getConfig(configFileName, secret); + const auth: IAuth = new Auth(config); + const spy = jest.spyOn(auth, methodToSpy); + const spyNotCalled = jest.spyOn(auth, methodNotBeenCalled); + const user: RemoteUser = { + name: username, + real_groups: [], + groups: [] + }; + const token = await getApiToken(auth, config, user, password); + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spyNotCalled).not.toHaveBeenCalled(); + expect(token).toBeDefined(); + + return token; + } + + const verifyJWT = (token: string, user: string, password: string, secret: string) => { + const payload = verifyPayload(token, secret); + expect(payload.name).toBe(user); + expect(payload.groups).toBeDefined(); + expect(payload.real_groups).toBeDefined(); + }; + + const verifyAES = (token: string, user: string, password: string, secret: string) => { + const payload = aesDecrypt(convertPayloadToBase64(token), secret).toString('utf8'); + const content = payload.split(':'); + + expect(content[0]).toBe(user); + expect(content[0]).toBe(password); + }; + + describe('getApiToken test', () => { + test('should sign token with aes and security missing', async () => { + const token = await signCredentials('security-missing', + 'test', 'test', '1234567', 'aesEncrypt', 'jwtEncrypt'); + + verifyAES(token, 'test', 'test', '1234567'); + expect(_.isString(token)).toBeTruthy(); + }); + + test('should sign token with aes and security emtpy', async () => { + const token = await signCredentials('security-empty', + 'test', 'test', '123456', 'aesEncrypt', 'jwtEncrypt'); + + verifyAES(token, 'test', 'test', '123456'); + expect(_.isString(token)).toBeTruthy(); + }); + + test('should sign token with aes', async () => { + const token = await signCredentials('security-basic', + 'test', 'test', '123456', 'aesEncrypt', 'jwtEncrypt'); + + verifyAES(token, 'test', 'test', '123456'); + expect(_.isString(token)).toBeTruthy(); + }); + + test('should sign token with legacy and jwt disabled', async () => { + const token = await signCredentials('security-no-legacy', + 'test', 'test', 'x8T#ZCx=2t', 'aesEncrypt', 'jwtEncrypt'); + + expect(_.isString(token)).toBeTruthy(); + verifyAES(token, 'test', 'test', 'x8T#ZCx=2t'); + }); + + test('should sign token with legacy enabled and jwt enabled', async () => { + const token = await signCredentials('security-jwt-legacy-enabled', + 'test', 'test', 'secret', 'jwtEncrypt', 'aesEncrypt'); + + verifyJWT(token, 'test', 'test', 'secret'); + expect(_.isString(token)).toBeTruthy(); + }); + + test('should sign token with jwt enabled', async () => { + const token = await signCredentials('security-jwt', + 'test', 'test', 'secret', 'jwtEncrypt', 'aesEncrypt'); + + expect(_.isString(token)).toBeTruthy(); + verifyJWT(token, 'test', 'test', 'secret'); + }); + + test('should sign with jwt whether legacy is disabled', async () => { + const token = await signCredentials('security-legacy-disabled', + 'test', 'test', 'secret', 'jwtEncrypt', 'aesEncrypt'); + + expect(_.isString(token)).toBeTruthy(); + verifyJWT(token, 'test', 'test', 'secret'); + }); + }); + + describe('getAuthenticatedMessage test', () => { + test('should sign token with jwt enabled', () => { + expect(getAuthenticatedMessage('test')).toBe('you are authenticated as \'test\''); + }); + }); + + describe('getMiddlewareCredentials test', () => { + describe('should get AES credentials', () => { + test.concurrent('should unpack aes token and credentials', async () => { + const secret: string = 'secret'; + const user: string = 'test'; + const pass: string = 'test'; + const token = await signCredentials('security-legacy', + user, pass, secret, 'aesEncrypt', 'jwtEncrypt'); + const config: Config = getConfig('security-legacy', secret); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, secret, `Bearer ${token}`); + expect(credentials).toBeDefined(); + // $FlowFixMe + expect(credentials.user).toEqual(user); + // $FlowFixMe + expect(credentials.password).toEqual(pass); + }); + + test.concurrent('should unpack aes token and credentials', async () => { + const secret: string = 'secret'; + const user: string = 'test'; + const pass: string = 'test'; + const token = buildUserBuffer(user, pass).toString('base64'); + const config: Config = getConfig('security-legacy', secret); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, secret, `Basic ${token}`); + expect(credentials).toBeDefined(); + // $FlowFixMe + expect(credentials.user).toEqual(user); + // $FlowFixMe + expect(credentials.password).toEqual(pass); + }); + + test.concurrent('should return empty credential wrong secret key', async () => { + const secret: string = 'secret'; + const token = await signCredentials('security-legacy', + 'test', 'test', secret, 'aesEncrypt', 'jwtEncrypt'); + const config: Config = getConfig('security-legacy', secret); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, 'BAD_SECRET', `Bearer ${token}`); + expect(credentials).not.toBeDefined(); + }); + + test.concurrent('should return empty credential wrong scheme', async () => { + const secret: string = 'secret'; + const token = await signCredentials('security-legacy', + 'test', 'test', secret, 'aesEncrypt', 'jwtEncrypt'); + const config: Config = getConfig('security-legacy', secret); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, secret, `BAD_SCHEME ${token}`); + expect(credentials).not.toBeDefined(); + }); + + test.concurrent('should return empty credential corrupted payload', async () => { + const secret: string = 'secret'; + const config: Config = getConfig('security-legacy', secret); + const auth: IAuth = new Auth(config); + const token = auth.aesEncrypt(new Buffer(`corruptedBuffer`)).toString('base64'); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, secret, `Bearer ${token}`); + expect(credentials).not.toBeDefined(); + }); + }); + + describe('should get JWT credentials', () => { + test('should return anonymous whether token is corrupted', () => { + const config: Config = getConfig('security-jwt', '12345'); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, '12345', 'Bearer fakeToken'); + + expect(credentials).toBeDefined(); + // $FlowFixMe + expect(credentials.name).not.toBeDefined(); + // $FlowFixMe + expect(credentials.real_groups).toBeDefined(); + // $FlowFixMe + expect(credentials.real_groups).toEqual([]); + }); + + test('should return anonymous whether token and scheme are corrupted', () => { + const config: Config = getConfig('security-jwt', '12345'); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, '12345', 'FakeScheme fakeToken'); + + expect(credentials).not.toBeDefined(); + }); + + test('should verify succesfully a JWT token', async () => { + const secret: string = 'secret'; + const user: string = 'test'; + const config: Config = getConfig('security-jwt', secret); + const token = await signCredentials('security-jwt', + user, 'secretTest', secret, 'jwtEncrypt', 'aesEncrypt'); + const security: Security = getSecurity(config); + const credentials = getMiddlewareCredentials(security, secret, `Bearer ${token}`); + expect(credentials).toBeDefined(); + // $FlowFixMe + expect(credentials.name).toEqual(user); + // $FlowFixMe + expect(credentials.real_groups).toBeDefined(); + // $FlowFixMe + expect(credentials.real_groups).toEqual([]); + }); + }); + }); +}); diff --git a/test/unit/api/auth.spec.js b/test/unit/api/auth.spec.js index cdc68d896..0c88c9de1 100644 --- a/test/unit/api/auth.spec.js +++ b/test/unit/api/auth.spec.js @@ -11,107 +11,111 @@ import {setup} from '../../../src/lib/logger'; import type {IAuth} from '../../../types/index'; import type {Config} from '@verdaccio/types'; +const authConfig = Object.assign({}, configExample); +// avoid noisy log output +authConfig.logs = [{type: 'stdout', format: 'pretty', level: 'error'}]; + setup(configExample.logs); describe('AuthTest', () => { - test('should be defined', () => { - const config: Config = new AppConfig(configExample); - const auth: IAuth = new Auth(config); + test('should be defined', () => { + const config: Config = new AppConfig(authConfig); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); - }); + expect(auth).toBeDefined(); + }); - describe('authenticate', () => { - test('should utilize plugin', () => { - const config: Config = new AppConfig(configPlugins); - const auth: IAuth = new Auth(config); + describe('test authenticate method', () => { + test('should utilize plugin', () => { + const config: Config = new AppConfig(configPlugins); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); + expect(auth).toBeDefined(); - const callback = jest.fn(); - const result = [ "test" ]; + const callback = jest.fn(); + const result = [ "test" ]; - // $FlowFixMe - auth.authenticate(1, null, callback); - // $FlowFixMe - auth.authenticate(null, result, callback); + // $FlowFixMe + auth.authenticate(1, null, callback); + // $FlowFixMe + auth.authenticate(null, result, callback); - expect(callback.mock.calls).toHaveLength(2); - expect(callback.mock.calls[0][0]).toBe(1); - expect(callback.mock.calls[0][1]).toBeUndefined(); - expect(callback.mock.calls[1][0]).toBeNull(); - expect(callback.mock.calls[1][1].real_groups).toBe(result); - }); + expect(callback.mock.calls).toHaveLength(2); + expect(callback.mock.calls[0][0]).toBe(1); + expect(callback.mock.calls[0][1]).toBeUndefined(); + expect(callback.mock.calls[1][0]).toBeNull(); + expect(callback.mock.calls[1][1].real_groups).toBe(result); + }); - test('should skip falsy values', () => { - const config: Config = new AppConfig(configPlugins); - const auth: IAuth = new Auth(config); + test('should skip falsy values', () => { + const config: Config = new AppConfig(configPlugins); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); + expect(auth).toBeDefined(); - const callback = jest.fn(); - let index = 0; + const callback = jest.fn(); + let index = 0; - // as defined by https://developer.mozilla.org/en-US/docs/Glossary/Falsy - for (const value of [ false, 0, "", null, undefined, NaN ]) { - // $FlowFixMe - auth.authenticate(null, value, callback); - const call = callback.mock.calls[index++]; - expect(call[0]).toBeDefined(); - expect(call[1]).toBeUndefined(); - } - }); + // as defined by https://developer.mozilla.org/en-US/docs/Glossary/Falsy + for (const value of [ false, 0, "", null, undefined, NaN ]) { + // $FlowFixMe + auth.authenticate(null, value, callback); + const call = callback.mock.calls[index++]; + expect(call[0]).toBeDefined(); + expect(call[1]).toBeUndefined(); + } + }); - test('should error truthy non-array', () => { - const config: Config = new AppConfig(configPlugins); - const auth: IAuth = new Auth(config); + test('should error truthy non-array', () => { + const config: Config = new AppConfig(configPlugins); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); + expect(auth).toBeDefined(); - const callback = jest.fn(); + const callback = jest.fn(); - for (const value of [ true, 1, "test", { } ]) { - expect(function ( ) { - // $FlowFixMe - auth.authenticate(null, value, callback); - }).toThrow(TypeError); - expect(callback.mock.calls).toHaveLength(0); - } - }); + for (const value of [ true, 1, "test", { } ]) { + expect(function ( ) { + // $FlowFixMe + auth.authenticate(null, value, callback); + }).toThrow(TypeError); + expect(callback.mock.calls).toHaveLength(0); + } + }); - test('should skip empty array', () => { - const config: Config = new AppConfig(configPlugins); - const auth: IAuth = new Auth(config); + test('should skip empty array', () => { + const config: Config = new AppConfig(configPlugins); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); + expect(auth).toBeDefined(); - const callback = jest.fn(); - const value = [ ]; + const callback = jest.fn(); + const value = [ ]; - // $FlowFixMe - auth.authenticate(null, value, callback); - expect(callback.mock.calls).toHaveLength(1); - expect(callback.mock.calls[0][0]).toBeDefined(); - expect(callback.mock.calls[0][1]).toBeUndefined(); - }); + // $FlowFixMe + auth.authenticate(null, value, callback); + expect(callback.mock.calls).toHaveLength(1); + expect(callback.mock.calls[0][0]).toBeDefined(); + expect(callback.mock.calls[0][1]).toBeUndefined(); + }); - test('should accept valid array', () => { - const config: Config = new AppConfig(configPlugins); - const auth: IAuth = new Auth(config); + test('should accept valid array', () => { + const config: Config = new AppConfig(configPlugins); + const auth: IAuth = new Auth(config); - expect(auth).toBeDefined(); + expect(auth).toBeDefined(); - const callback = jest.fn(); - let index = 0; + const callback = jest.fn(); + let index = 0; - for (const value of [ [ "" ], [ "1" ], [ "0" ], ["000"] ]) { - // $FlowFixMe - auth.authenticate(null, value, callback); - const call = callback.mock.calls[index++]; - expect(call[0]).toBeNull(); - expect(call[1].real_groups).toBe(value); - } - }); - }) + for (const value of [ [ "" ], [ "1" ], [ "0" ], ["000"] ]) { + // $FlowFixMe + auth.authenticate(null, value, callback); + const call = callback.mock.calls[index++]; + expect(call[0]).toBeNull(); + expect(call[1].real_groups).toBe(value); + } + }); + }) }); diff --git a/test/unit/api/config-utils.spec.js b/test/unit/api/config-utils.spec.js index 667db85b5..f94bb794c 100644 --- a/test/unit/api/config-utils.spec.js +++ b/test/unit/api/config-utils.spec.js @@ -13,19 +13,19 @@ import {PACKAGE_ACCESS, ROLES} from '../../../src/lib/constants'; describe('Config Utilities', () => { - const parsePartial = (name) => { + const parseConfigurationFile = (name) => { return path.join(__dirname, `../partials/config/yaml/${name}.yaml`); }; describe('uplinkSanityCheck', () => { test('should test basic conversion', ()=> { - const uplinks = uplinkSanityCheck(parseConfigFile(parsePartial('uplink-basic')).uplinks); + const uplinks = uplinkSanityCheck(parseConfigFile(parseConfigurationFile('uplink-basic')).uplinks); expect(Object.keys(uplinks)).toContain('server1'); expect(Object.keys(uplinks)).toContain('server2'); }); test('should throw error on blacklisted uplink name', ()=> { - const {uplinks} = parseConfigFile(parsePartial('uplink-wrong')); + const {uplinks} = parseConfigFile(parseConfigurationFile('uplink-wrong')); expect(() => { uplinkSanityCheck(uplinks) @@ -35,7 +35,7 @@ describe('Config Utilities', () => { describe('sanityCheckUplinksProps', () => { test('should fails if url prop is missing', ()=> { - const {uplinks} = parseConfigFile(parsePartial('uplink-wrong')); + const {uplinks} = parseConfigFile(parseConfigurationFile('uplink-wrong')); expect(() => { sanityCheckUplinksProps(uplinks) }).toThrow('CONFIG: no url for uplink: none-url'); @@ -48,7 +48,7 @@ describe('Config Utilities', () => { describe('normalisePackageAccess', () => { test('should test basic conversion', ()=> { - const {packages} = parseConfigFile(parsePartial('pkgs-basic')); + const {packages} = parseConfigFile(parseConfigurationFile('pkgs-basic')); const access = normalisePackageAccess(packages); expect(access).toBeDefined(); @@ -60,7 +60,7 @@ describe('Config Utilities', () => { }); test('should test multi group', ()=> { - const {packages} = parseConfigFile(parsePartial('pkgs-multi-group')); + const {packages} = parseConfigFile(parseConfigurationFile('pkgs-multi-group')); const access = normalisePackageAccess(packages); expect(access).toBeDefined(); @@ -84,7 +84,7 @@ describe('Config Utilities', () => { test('should deprecated packages props', ()=> { - const {packages} = parseConfigFile(parsePartial('deprecated-pkgs-basic')); + const {packages} = parseConfigFile(parseConfigurationFile('deprecated-pkgs-basic')); const access = normalisePackageAccess(packages); expect(access).toBeDefined(); @@ -118,7 +118,7 @@ describe('Config Utilities', () => { }); test('should check not default packages access', ()=> { - const {packages} = parseConfigFile(parsePartial('pkgs-empty')); + const {packages} = parseConfigFile(parseConfigurationFile('pkgs-empty')); const access = normalisePackageAccess(packages); expect(access).toBeDefined(); @@ -137,7 +137,7 @@ describe('Config Utilities', () => { describe('getMatchedPackagesSpec', () => { test('should test basic config', () => { - const {packages} = parseConfigFile(parsePartial('pkgs-custom')); + const {packages} = parseConfigFile(parseConfigurationFile('pkgs-custom')); // $FlowFixMe expect(getMatchedPackagesSpec('react', packages).proxy).toMatch('facebook'); // $FlowFixMe @@ -149,7 +149,7 @@ describe('Config Utilities', () => { }); test('should test no ** wildcard on config', () => { - const {packages} = parseConfigFile(parsePartial('pkgs-nosuper-wildcard-custom')); + const {packages} = parseConfigFile(parseConfigurationFile('pkgs-nosuper-wildcard-custom')); // $FlowFixMe expect(getMatchedPackagesSpec('react', packages).proxy).toMatch('facebook'); // $FlowFixMe @@ -163,7 +163,7 @@ describe('Config Utilities', () => { describe('hasProxyTo', () => { test('should test basic config', () => { - const packages = normalisePackageAccess(parseConfigFile(parsePartial('pkgs-basic')).packages); + const packages = normalisePackageAccess(parseConfigFile(parseConfigurationFile('pkgs-basic')).packages); // react expect(hasProxyTo('react', 'facebook', packages)).toBeFalsy(); expect(hasProxyTo('react', 'google', packages)).toBeFalsy(); @@ -178,7 +178,7 @@ describe('Config Utilities', () => { }); test('should test resolve based on custom package access', () => { - const packages = normalisePackageAccess(parseConfigFile(parsePartial('pkgs-custom')).packages); + const packages = normalisePackageAccess(parseConfigFile(parseConfigurationFile('pkgs-custom')).packages); // react expect(hasProxyTo('react', 'facebook', packages)).toBeTruthy(); expect(hasProxyTo('react', 'google', packages)).toBeFalsy(); @@ -193,7 +193,7 @@ describe('Config Utilities', () => { }); test('should not resolve any proxy', () => { - const packages = normalisePackageAccess(parseConfigFile(parsePartial('pkgs-empty')).packages); + const packages = normalisePackageAccess(parseConfigFile(parseConfigurationFile('pkgs-empty')).packages); // react expect(hasProxyTo('react', 'npmjs', packages)).toBeFalsy(); expect(hasProxyTo('react', 'npmjs', packages)).toBeFalsy(); diff --git a/test/unit/partials/config/yaml/api-jwt/jwt.yaml b/test/unit/partials/config/yaml/api-jwt/jwt.yaml new file mode 100644 index 000000000..69bcfd118 --- /dev/null +++ b/test/unit/partials/config/yaml/api-jwt/jwt.yaml @@ -0,0 +1,36 @@ +storage: ./storage +plugins: ./plugins + +web: + title: Verdaccio + +auth: + htpasswd: + file: ./htpasswd +uplinks: + npmjs: + url: https://registry.npmjs.org/ +security: + api: + jwt: + sign: + expiresIn: 10m + notBefore: 0 +packages: + '@*/*': + access: $all + publish: $authenticated + proxy: npmjs + 'vue': + access: $authenticated + publish: $authenticated + proxy: npmjs + '**': + access: $all + publish: $authenticated + proxy: npmjs +middlewares: + audit: + enabled: true +logs: + - {type: stdout, format: pretty, level: http} diff --git a/test/unit/partials/config/yaml/security/security-basic.yaml b/test/unit/partials/config/yaml/security/security-basic.yaml new file mode 100644 index 000000000..11679dbc1 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-basic.yaml @@ -0,0 +1,12 @@ +security: + api: + legacy: true # use AES algorithm + # jwt enables json web token and disable legacy + # jwt: https://github.com/auth0/node-jsonwebtoken#jwtsignpayload-secretorprivatekey-options-callback + sign: + expiresIn: 7d # 7 days by default + # verify: + web: + sign: + expiresIn: 7d # 7 days by default + # verify: https://github.com/auth0/node-jsonwebtoken#jwtverifytoken-secretorpublickey-options-callback diff --git a/test/unit/partials/config/yaml/security/security-empty.yaml b/test/unit/partials/config/yaml/security/security-empty.yaml new file mode 100644 index 000000000..9a4b4c9f7 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-empty.yaml @@ -0,0 +1 @@ +security: diff --git a/test/unit/partials/config/yaml/security/security-jwt-legacy-enabled.yaml b/test/unit/partials/config/yaml/security/security-jwt-legacy-enabled.yaml new file mode 100644 index 000000000..a6d13bb79 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-jwt-legacy-enabled.yaml @@ -0,0 +1,10 @@ +security: + api: + legacy: true + jwt: + sign: + expiresIn: 7d + notBefore: 0 + web: + sign: + expiresIn: 7d diff --git a/test/unit/partials/config/yaml/security/security-jwt.yaml b/test/unit/partials/config/yaml/security/security-jwt.yaml new file mode 100644 index 000000000..075a85201 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-jwt.yaml @@ -0,0 +1,6 @@ +security: + api: + jwt: + sign: + expiresIn: 7d + notBefore: 0 diff --git a/test/unit/partials/config/yaml/security/security-legacy-disabled.yaml b/test/unit/partials/config/yaml/security/security-legacy-disabled.yaml new file mode 100644 index 000000000..3ed6e7792 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-legacy-disabled.yaml @@ -0,0 +1,3 @@ +security: + api: + legacy: false diff --git a/test/unit/partials/config/yaml/security/security-legacy.yaml b/test/unit/partials/config/yaml/security/security-legacy.yaml new file mode 100644 index 000000000..6602c9daf --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-legacy.yaml @@ -0,0 +1,3 @@ +security: + api: + legacy: true diff --git a/test/unit/partials/config/yaml/security/security-missing.yaml b/test/unit/partials/config/yaml/security/security-missing.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/partials/config/yaml/security/security-no-legacy.yaml b/test/unit/partials/config/yaml/security/security-no-legacy.yaml new file mode 100644 index 000000000..dba590e79 --- /dev/null +++ b/test/unit/partials/config/yaml/security/security-no-legacy.yaml @@ -0,0 +1,9 @@ +security: + api: + legacy: false + sign: + expiresIn: 7d + notBefore: 0 + web: + sign: + expiresIn: 7d diff --git a/test/unit/partials/store/storage/.sinopia-db.json b/test/unit/partials/store/storage/.sinopia-db.json new file mode 100644 index 000000000..769c4f86f --- /dev/null +++ b/test/unit/partials/store/storage/.sinopia-db.json @@ -0,0 +1 @@ +{"list":[],"secret":"c884521349204fdb6665e27a20c04dd1fb81863a56fa68cbbab39aebea0dd609"} \ No newline at end of file diff --git a/types/index.js b/types/index.js index e0e6eba2c..7dd745e90 100644 --- a/types/index.js +++ b/types/index.js @@ -8,8 +8,10 @@ import type { Callback, Versions, Version, + RemoteUser, Config, Logger, + JWTSignOptions, PackageAccess, StringValue as verdaccio$StringValue, Package} from '@verdaccio/types'; @@ -30,19 +32,31 @@ export type StartUpConfig = { export type MatchedPackage = PackageAccess | void; -export type JWTPayload = { - user: string; - group: string | void; +export type JWTPayload = RemoteUser & { + password?: string; } -export type JWTSignOptions = { - expiresIn: string; +export type AESPayload = { + user: string; + password: string; } +export type AuthTokenHeader = { + scheme: string; + token: string; +} + +export type BasicPayload = AESPayload | void; +export type AuthMiddlewarePayload = RemoteUser | BasicPayload; + export type ProxyList = { [key: string]: IProxy; } +export type CookieSessionToken = { + expires: Date; +} + export type Utils = { ErrorCode: any; getLatestVersion: Callback; @@ -59,8 +73,9 @@ export type $NextFunctionVer = NextFunction & mixed; export type $SidebarPackage = Package & {latest: mixed} -interface IAuthWebUI { - issueUIjwt(user: string, time: string): string; +export interface IAuthWebUI { + jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): string; + aesEncrypt(buf: Buffer): Buffer; } interface IAuthMiddleware { diff --git a/yarn.lock b/yarn.lock index a6b814599..603bec0ef 100644 Binary files a/yarn.lock and b/yarn.lock differ