diff --git a/.changeset/tender-tigers-hammer.md b/.changeset/tender-tigers-hammer.md new file mode 100644 index 000000000..784b9e20e --- /dev/null +++ b/.changeset/tender-tigers-hammer.md @@ -0,0 +1,7 @@ +--- +'@verdaccio/auth': minor +'@verdaccio/core': minor +'verdaccio-htpasswd': minor +--- + +feat: async bcrypt hash diff --git a/packages/auth/test/auth.spec.ts b/packages/auth/test/auth.spec.ts index 6003657ac..ac954e62d 100644 --- a/packages/auth/test/auth.spec.ts +++ b/packages/auth/test/auth.spec.ts @@ -8,7 +8,7 @@ import { Config } from '@verdaccio/types'; import { Auth } from '../src'; import { authPluginFailureConf, authPluginPassThrougConf, authProfileConf } from './helper/plugin'; -setup({}); +setup({ level: 'debug', type: 'stdout' }); describe('AuthTest', () => { test('should init correctly', async () => { @@ -29,6 +29,18 @@ describe('AuthTest', () => { expect(auth).toBeDefined(); }); + test('should load custom algorithm', async () => { + const config: Config = new AppConfig({ + ...authProfileConf, + auth: { htpasswd: { algorithm: 'sha1', file: './foo' } }, + }); + config.checkSecretKey('12345'); + + const auth: Auth = new Auth(config); + await auth.init(); + expect(auth).toBeDefined(); + }); + describe('test authenticate method', () => { describe('test authenticate states', () => { test('should be a success login', async () => { diff --git a/packages/core/core/src/constants.ts b/packages/core/core/src/constants.ts index 781167c17..47c684e11 100644 --- a/packages/core/core/src/constants.ts +++ b/packages/core/core/src/constants.ts @@ -109,3 +109,10 @@ export const PACKAGE_ACCESS = { SCOPE: '@*/*', ALL: '**', }; + +export enum HtpasswdHashAlgorithm { + md5 = 'md5', + sha1 = 'sha1', + crypt = 'crypt', + bcrypt = 'bcrypt', +} diff --git a/packages/core/core/src/index.ts b/packages/core/core/src/index.ts index 932d7ff64..88a45630e 100644 --- a/packages/core/core/src/index.ts +++ b/packages/core/core/src/index.ts @@ -23,6 +23,7 @@ export { DEFAULT_PASSWORD_VALIDATION, DEFAULT_USER, USERS, + HtpasswdHashAlgorithm, } from './constants'; const validationUtils = validatioUtils; export { diff --git a/packages/core/types/src/configuration.ts b/packages/core/types/src/configuration.ts index e613b9b32..a7af0e7ad 100644 --- a/packages/core/types/src/configuration.ts +++ b/packages/core/types/src/configuration.ts @@ -18,9 +18,6 @@ export type LoggerLevel = 'http' | 'fatal' | 'warn' | 'info' | 'debug' | 'trace' export type LoggerConfigItem = { type?: LoggerType; - /** - * The format - */ format?: LoggerFormat; path?: string; level?: string; diff --git a/packages/plugins/htpasswd/src/htpasswd.ts b/packages/plugins/htpasswd/src/htpasswd.ts index fa034e83c..a3c749bc5 100644 --- a/packages/plugins/htpasswd/src/htpasswd.ts +++ b/packages/plugins/htpasswd/src/htpasswd.ts @@ -2,12 +2,12 @@ import buildDebug from 'debug'; import fs from 'fs'; import { dirname, resolve } from 'path'; -import { pluginUtils } from '@verdaccio/core'; +import { constants, pluginUtils } from '@verdaccio/core'; import { unlockFile } from '@verdaccio/file-locking'; import { Callback, Logger } from '@verdaccio/types'; import { - HtpasswdHashAlgorithm, + DEFAULT_BCRYPT_ROUNDS, HtpasswdHashConfig, addUserToHTPasswd, changePasswordToHTPasswd, @@ -17,6 +17,8 @@ import { verifyPassword, } from './utils'; +type HtpasswdHashAlgorithm = constants.HtpasswdHashAlgorithm; + const debug = buildDebug('verdaccio:plugin:htpasswd'); export type HTPasswdConfig = { @@ -27,7 +29,6 @@ export type HTPasswdConfig = { slow_verify_ms?: number; }; -export const DEFAULT_BCRYPT_ROUNDS = 10; export const DEFAULT_SLOW_VERIFY_MS = 200; /** @@ -63,15 +64,19 @@ export default class HTPasswd let algorithm: HtpasswdHashAlgorithm; let rounds: number | undefined; - if (config.algorithm === undefined) { - algorithm = HtpasswdHashAlgorithm.bcrypt; - } else if (HtpasswdHashAlgorithm[config.algorithm] !== undefined) { - algorithm = HtpasswdHashAlgorithm[config.algorithm]; + if (typeof config.algorithm === 'undefined') { + algorithm = constants.HtpasswdHashAlgorithm.bcrypt; + } else if (constants.HtpasswdHashAlgorithm[config.algorithm] !== undefined) { + algorithm = constants.HtpasswdHashAlgorithm[config.algorithm]; } else { - throw new Error(`Invalid algorithm "${config.algorithm}"`); + this.logger.warn( + `The algorithm selected %s is invalid, switching to to default one "bcrypt", password validation can be affected`, + config.algorithm + ); + algorithm = constants.HtpasswdHashAlgorithm.bcrypt; } debug(`password hash algorithm: ${algorithm}`); - if (algorithm === HtpasswdHashAlgorithm.bcrypt) { + if (algorithm === constants.HtpasswdHashAlgorithm.bcrypt) { rounds = config.rounds || DEFAULT_BCRYPT_ROUNDS; } else if (config.rounds !== undefined) { this.logger.warn({ algo: algorithm }, 'Option "rounds" is not valid for "@{algo}" algorithm'); @@ -202,7 +207,7 @@ export default class HTPasswd } try { - this._writeFile(addUserToHTPasswd(body, user, password, this.hashConfig), cb); + this._writeFile(await addUserToHTPasswd(body, user, password, this.hashConfig), cb); } catch (err: any) { return cb(err); } diff --git a/packages/plugins/htpasswd/src/utils.ts b/packages/plugins/htpasswd/src/utils.ts index 87e2e7fd1..c9a15df76 100644 --- a/packages/plugins/htpasswd/src/utils.ts +++ b/packages/plugins/htpasswd/src/utils.ts @@ -3,18 +3,15 @@ import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import createError, { HttpError } from 'http-errors'; -import { API_ERROR, HTTP_STATUS } from '@verdaccio/core'; +import { API_ERROR, HTTP_STATUS, constants } from '@verdaccio/core'; import { readFile } from '@verdaccio/file-locking'; import { Callback } from '@verdaccio/types'; import crypt3 from './crypt3'; -export enum HtpasswdHashAlgorithm { - md5 = 'md5', - sha1 = 'sha1', - crypt = 'crypt', - bcrypt = 'bcrypt', -} +export const DEFAULT_BCRYPT_ROUNDS = 10; + +type HtpasswdHashAlgorithm = constants.HtpasswdHashAlgorithm; export interface HtpasswdHashConfig { algorithm: HtpasswdHashAlgorithm; @@ -80,24 +77,24 @@ export async function verifyPassword(passwd: string, hash: string): Promise { let hash: string; switch (hashConfig.algorithm) { - case HtpasswdHashAlgorithm.bcrypt: - hash = bcrypt.hashSync(passwd, hashConfig.rounds); + case constants.HtpasswdHashAlgorithm.bcrypt: + hash = await bcrypt.hash(passwd, hashConfig.rounds || DEFAULT_BCRYPT_ROUNDS); break; - case HtpasswdHashAlgorithm.crypt: + case constants.HtpasswdHashAlgorithm.crypt: hash = crypt3(passwd); break; - case HtpasswdHashAlgorithm.md5: + case constants.HtpasswdHashAlgorithm.md5: hash = md5(passwd); break; - case HtpasswdHashAlgorithm.sha1: + case constants.HtpasswdHashAlgorithm.sha1: hash = '{SHA}' + crypto.createHash('sha1').update(passwd, 'utf8').digest('base64'); break; default: @@ -116,12 +113,12 @@ export function generateHtpasswdLine( * @param {HtpasswdHashConfig} hashConfig * @returns {string} */ -export function addUserToHTPasswd( +export async function addUserToHTPasswd( body: string, user: string, passwd: string, hashConfig: HtpasswdHashConfig -): string { +): Promise { if (user !== encodeURIComponent(user)) { const err = createError('username should not contain non-uri-safe characters'); @@ -129,7 +126,7 @@ export function addUserToHTPasswd( throw err; } - let newline = generateHtpasswdLine(user, passwd, hashConfig); + let newline = await generateHtpasswdLine(user, passwd, hashConfig); if (body.length && body[body.length - 1] !== '\n') { newline = '\n' + newline; @@ -190,13 +187,14 @@ export async function sanityCheck( } /** + * /** * changePasswordToHTPasswd - change password for existing user * @param {string} body * @param {string} user * @param {string} passwd * @param {string} newPasswd * @param {HtpasswdHashConfig} hashConfig - * @returns {string} + * @returns {Promise} */ export async function changePasswordToHTPasswd( body: string, @@ -215,7 +213,7 @@ export async function changePasswordToHTPasswd( if (!passwordValid) { throw new Error(`Unable to change password for user '${user}': invalid old password`); } - const updatedUserLine = generateHtpasswdLine(username, newPasswd, hashConfig); + const updatedUserLine = await generateHtpasswdLine(username, newPasswd, hashConfig); lines.splice(userLineIndex, 1, updatedUserLine); return lines.join('\n'); } diff --git a/packages/plugins/htpasswd/tests/htpasswd.test.ts b/packages/plugins/htpasswd/tests/htpasswd.test.ts index 9192a0c20..cc23b7da9 100644 --- a/packages/plugins/htpasswd/tests/htpasswd.test.ts +++ b/packages/plugins/htpasswd/tests/htpasswd.test.ts @@ -1,26 +1,18 @@ -/* eslint-disable jest/no-mocks-import */ -// @ts-ignore: Module has no default export import bcrypt from 'bcryptjs'; -// @ts-ignore: Module has no default export import crypto from 'crypto'; -// @ts-ignore: Module has no default export import fs from 'fs'; import MockDate from 'mockdate'; import path from 'path'; import { Config, parseConfigFile } from '@verdaccio/config'; -import { logger, setup } from '@verdaccio/logger'; -import { PluginOptions } from '@verdaccio/types'; +import { constants, pluginUtils } from '@verdaccio/core'; import HTPasswd, { DEFAULT_SLOW_VERIFY_MS, HTPasswdConfig } from '../src/htpasswd'; -import { HtpasswdHashAlgorithm } from '../src/utils'; - -setup(); const options = { - logger, + logger: { warn: jest.fn(), info: jest.fn() }, config: new Config(parseConfigFile(path.join(__dirname, './__fixtures__/config.yaml'))), -} as any as PluginOptions; +} as any as pluginUtils.PluginOptions; const config = { file: './htpasswd', @@ -34,7 +26,8 @@ describe('HTPasswd', () => { wrapper = new HTPasswd(config, options); jest.resetModules(); jest.clearAllMocks(); - // @ts-ignore: Module has no default export + + // @ts-ignore crypto.randomBytes = jest.fn(() => { return { toString: (): string => '$6', @@ -43,7 +36,15 @@ describe('HTPasswd', () => { }); describe('constructor()', () => { - const emptyPluginOptions = { config: {} } as any as PluginOptions; + const error = jest.fn(); + const warn = jest.fn(); + const info = jest.fn(); + const emptyPluginOptions = { + config: { + configPath: '', + }, + logger: { warn, info, error }, + } as any as pluginUtils.PluginOptions; test('should ensure file path configuration exists', () => { expect(function () { @@ -51,11 +52,14 @@ describe('HTPasswd', () => { }).toThrow(/should specify "file" in config/); }); - test('should throw error about incorrect algorithm', () => { - expect(function () { - let invalidConfig = { algorithm: 'invalid', ...config } as HTPasswdConfig; - new HTPasswd(invalidConfig, emptyPluginOptions); - }).toThrow(/Invalid algorithm "invalid"/); + test('should switch to bcrypt if incorrect algorithm is set', () => { + let invalidConfig = { algorithm: 'invalid', ...config } as HTPasswdConfig; + new HTPasswd(invalidConfig, emptyPluginOptions); + expect(warn).toHaveBeenCalledWith( + 'The algorithm selected %s is invalid, switching to to default one "bcrypt", password validation can be affected', + 'invalid' + ); + expect(info).toHaveBeenCalled(); }); }); @@ -95,21 +99,20 @@ describe('HTPasswd', () => { test('it should warn on slow password verification', (done) => { // @ts-ignore // eslint-disable-next-line @typescript-eslint/no-unused-vars - bcrypt.compare = jest.fn(async (_passwd, _hash) => { - await new Promise((resolve) => setTimeout(resolve, DEFAULT_SLOW_VERIFY_MS + 1)); - return true; + bcrypt.compare = jest.fn((_passwd, _hash) => { + return new Promise((resolve) => setTimeout(resolve, DEFAULT_SLOW_VERIFY_MS + 1)).then( + () => true + ); }); const callback = (a, b): void => { expect(a).toBeNull(); expect(b).toContain('bcrypt'); - // TODO: figure out how to test the warning properly without mocking the logger - // maybe mocking pino? not sure. - // const mockWarn = options.logger.warn as jest.MockedFn; - // expect(mockWarn.mock.calls.length).toBe(1); - // const [{ user, durationMs }, message] = mockWarn.mock.calls[0]; - // expect(user).toEqual('bcrypt'); - // expect(durationMs).toBeGreaterThan(DEFAULT_SLOW_VERIFY_MS); - // expect(message).toEqual('Password for user "@{user}" took @{durationMs}ms to verify'); + const mockWarn = options.logger.warn as jest.MockedFn; + expect(mockWarn.mock.calls.length).toBe(1); + const [{ user, durationMs }, message] = mockWarn.mock.calls[0]; + expect(user).toEqual('bcrypt'); + expect(durationMs).toBeGreaterThan(DEFAULT_SLOW_VERIFY_MS); + expect(message).toEqual('Password for user "@{user}" took @{durationMs}ms to verify'); done(); }; wrapper.authenticate('bcrypt', 'password', callback); @@ -128,7 +131,7 @@ describe('HTPasswd', () => { test('it should add the user', (done) => { let dataToWrite; // @ts-ignore - fs.writeFile = jest.fn((_name, data, callback) => { + fs.writeFile = jest.fn((name, data, callback) => { dataToWrite = data; callback(); }); @@ -150,7 +153,7 @@ describe('HTPasswd', () => { jest.doMock('../src/utils.ts', () => { return { sanityCheck: (): Error => Error('some error'), - HtpasswdHashAlgorithm, + HtpasswdHashAlgorithm: constants.HtpasswdHashAlgorithm, }; }); @@ -168,7 +171,7 @@ describe('HTPasswd', () => { return { sanityCheck: (): any => null, lockAndRead: (_a, b): any => b(new Error('lock error')), - HtpasswdHashAlgorithm, + HtpasswdHashAlgorithm: constants.HtpasswdHashAlgorithm, }; }); @@ -188,7 +191,7 @@ describe('HTPasswd', () => { parseHTPasswd: (): void => {}, lockAndRead: (_a, b): any => b(null, ''), unlockFile: (_a, b): any => b(), - HtpasswdHashAlgorithm, + HtpasswdHashAlgorithm: constants.HtpasswdHashAlgorithm, }; }); @@ -202,11 +205,11 @@ describe('HTPasswd', () => { test('writeFile should return an Error', (done) => { jest.doMock('../src/utils.ts', () => { return { - sanityCheck: (): any => null, + sanityCheck: () => Promise.resolve(null), parseHTPasswd: (): void => {}, lockAndRead: (_a, b): any => b(null, ''), addUserToHTPasswd: (): void => {}, - HtpasswdHashAlgorithm, + HtpasswdHashAlgorithm: constants.HtpasswdHashAlgorithm, }; }); jest.doMock('fs', () => { @@ -246,9 +249,6 @@ describe('HTPasswd', () => { test('reload should fails on check file', (done) => { jest.doMock('fs', () => { return { - readFile: (_name, callback): void => { - callback(new Error('stat error'), null); - }, stat: (_name, callback): void => { callback(new Error('stat error'), null); }, @@ -268,9 +268,6 @@ describe('HTPasswd', () => { test('reload times match', (done) => { jest.doMock('fs', () => { return { - readFile: (_name, callback): void => { - callback(new Error('stat error'), null); - }, stat: (_name, callback): void => { callback(null, { mtime: null, diff --git a/packages/plugins/htpasswd/tests/utils.test.ts b/packages/plugins/htpasswd/tests/utils.test.ts index 5d8b7ae27..8f152c711 100644 --- a/packages/plugins/htpasswd/tests/utils.test.ts +++ b/packages/plugins/htpasswd/tests/utils.test.ts @@ -3,9 +3,10 @@ import crypto from 'crypto'; import { HttpError } from 'http-errors'; import MockDate from 'mockdate'; -import { DEFAULT_BCRYPT_ROUNDS } from '../src/htpasswd'; +import { constants } from '@verdaccio/core'; + +import { DEFAULT_BCRYPT_ROUNDS } from '../src/utils'; import { - HtpasswdHashAlgorithm, addUserToHTPasswd, changePasswordToHTPasswd, generateHtpasswdLine, @@ -19,7 +20,7 @@ const mockReadFile = jest.fn(); const mockUnlockFile = jest.fn(); const defaultHashConfig = { - algorithm: HtpasswdHashAlgorithm.bcrypt, + algorithm: constants.HtpasswdHashAlgorithm.bcrypt, rounds: DEFAULT_BCRYPT_ROUNDS, }; @@ -111,51 +112,56 @@ describe('generateHtpasswdLine', () => { const [user, passwd] = ['username', 'password']; - it('should correctly generate line for md5', () => { - const md5Conf = { algorithm: HtpasswdHashAlgorithm.md5 }; - expect(generateHtpasswdLine(user, passwd, md5Conf)).toMatchSnapshot(); + it('should correctly generate line for md5', async () => { + const md5Conf = { algorithm: constants.HtpasswdHashAlgorithm.md5 }; + expect(await generateHtpasswdLine(user, passwd, md5Conf)).toMatchSnapshot(); }); - it('should correctly generate line for sha1', () => { - const sha1Conf = { algorithm: HtpasswdHashAlgorithm.sha1 }; - expect(generateHtpasswdLine(user, passwd, sha1Conf)).toMatchSnapshot(); + it('should correctly generate line for sha1', async () => { + const sha1Conf = { algorithm: constants.HtpasswdHashAlgorithm.sha1 }; + expect(await generateHtpasswdLine(user, passwd, sha1Conf)).toMatchSnapshot(); }); - it('should correctly generate line for crypt', () => { - const cryptConf = { algorithm: HtpasswdHashAlgorithm.crypt }; - expect(generateHtpasswdLine(user, passwd, cryptConf)).toMatchSnapshot(); + it('should correctly generate line for crypt', async () => { + const cryptConf = { algorithm: constants.HtpasswdHashAlgorithm.crypt }; + expect(await generateHtpasswdLine(user, passwd, cryptConf)).toMatchSnapshot(); }); - it('should correctly generate line for bcrypt', () => { + it('should correctly generate line for bcrypt', async () => { const bcryptAlgoConfig = { - algorithm: HtpasswdHashAlgorithm.bcrypt, + algorithm: constants.HtpasswdHashAlgorithm.bcrypt, rounds: 2, }; - expect(generateHtpasswdLine(user, passwd, bcryptAlgoConfig)).toMatchSnapshot(); + expect(await generateHtpasswdLine(user, passwd, bcryptAlgoConfig)).toMatchSnapshot(); }); }); describe('addUserToHTPasswd - bcrypt', () => { beforeAll(mockTimeAndRandomBytes); - it('should add new htpasswd to the end', () => { + it('should add new htpasswd to the end', async () => { const input = ['', 'username', 'password']; - expect(addUserToHTPasswd(input[0], input[1], input[2], defaultHashConfig)).toMatchSnapshot(); + expect( + await addUserToHTPasswd(input[0], input[1], input[2], defaultHashConfig) + ).toMatchSnapshot(); }); - it('should add new htpasswd to the end in multiline input', () => { + it('should add new htpasswd to the end in multiline input', async () => { const body = `test1:$6b9MlB3WUELU:autocreated 2017-11-06T18:17:21.957Z test2:$6FrCaT/v0dwE:autocreated 2017-12-14T13:30:20.838Z`; const input = [body, 'username', 'password']; - expect(addUserToHTPasswd(input[0], input[1], input[2], defaultHashConfig)).toMatchSnapshot(); + expect( + await addUserToHTPasswd(input[0], input[1], input[2], defaultHashConfig) + ).toMatchSnapshot(); }); - it('should throw an error for incorrect username with space', () => { + it('should throw an error for incorrect username with space', async () => { const [a, b, c] = ['', 'firstname lastname', 'password']; - expect(() => addUserToHTPasswd(a, b, c, defaultHashConfig)).toThrowErrorMatchingSnapshot(); + await expect( + addUserToHTPasswd(a, b, c, defaultHashConfig) + ).rejects.toThrowErrorMatchingSnapshot(); }); }); - describe('lockAndRead', () => { it('should call the readFile method', () => { const cb = (): void => {};