0
Fork 0
mirror of https://github.com/verdaccio/verdaccio.git synced 2024-12-16 21:56:25 -05:00

refactor: auth with legacy sign support (#4113)

* refactor: auth with legacy sign support

refactor: auth with legacy sign support

add tests

add tests

clean up lock fil

clean up lock fil

add more ci to test

update ci

update ci

update ci

update ci

update ci

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature

* chore: add test for deprecated legacy signature
This commit is contained in:
Juan Picado 2023-12-31 14:34:29 +01:00 committed by GitHub
parent 5f8e361262
commit f047cc8c25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2202 additions and 2558 deletions

View file

@ -0,0 +1,14 @@
---
'@verdaccio/server': minor
'@verdaccio/test-helper': minor
'@verdaccio/types': minor
'@verdaccio/middleware': minor
'@verdaccio/core': minor
'@verdaccio/signature': minor
'@verdaccio/url': minor
'@verdaccio/config': minor
'@verdaccio/auth': minor
'@verdaccio/api': minor
---
refactor: auth with legacy sign support

View file

@ -100,12 +100,12 @@ jobs:
- name: Lint
run: pnpm format:check
test:
needs: [format, lint]
needs: [prepare]
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest]
node_version: [18, 20]
node_version: [18, 20, 21]
name: ${{ matrix.os }} / Node ${{ matrix.node_version }}
runs-on: ${{ matrix.os }}
steps:
@ -117,7 +117,7 @@ jobs:
- name: Install pnpm
run: |
corepack enable
corepack prepare --activate pnpm@8.9.0
corepack prepare
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: ~/.pnpm-store

View file

@ -23,7 +23,7 @@ jobs:
- name: Install pnpm
run: |
corepack enable
corepack prepare --activate pnpm@8.9.0
corepack prepare
- name: set store
run: |
mkdir ~/.pnpm-store
@ -49,7 +49,7 @@ jobs:
- name: Install pnpm
run: |
corepack enable
corepack prepare --activate pnpm@8.9.0
corepack prepare
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: ~/.pnpm-store
@ -77,7 +77,7 @@ jobs:
# key: test-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
# restore-keys: |
# test-
e2e-cli:
e2e-cli-npm:
needs: [prepare, build]
strategy:
fail-fast: false
@ -88,16 +88,9 @@ jobs:
npm7,
npm8,
npm9,
npm10,
pnpm6,
pnpm7,
pnpm8,
yarn1,
yarn2,
yarn3,
yarn4,
npm10
]
node: [16, 18, 19]
node: [20, 21]
name: ${{ matrix.pkg }}/ ubuntu-latest / ${{ matrix.node }}
runs-on: ubuntu-latest
steps:
@ -108,7 +101,7 @@ jobs:
- name: Install pnpm
run: |
corepack enable
corepack prepare --activate pnpm@8.9.0
corepack prepare
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: ~/.pnpm-store
@ -130,3 +123,94 @@ jobs:
run: pnpm --filter @verdaccio/test-cli-commons build
- name: Test CLI
run: NODE_ENV=production pnpm test --filter ...@verdaccio/e2e-cli-${{matrix.pkg}}
# TODO: fix pnpm setup
# e2e-cli-pnpm:
# needs: [prepare, build]
# strategy:
# fail-fast: true
# matrix:
# pkg:
# [
# pnpm6,
# pnpm7,
# pnpm8
# ]
# node: [20, 21]
# name: ${{ matrix.pkg }}/ ubuntu-latest / ${{ matrix.node }}
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
# - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
# with:
# node-version: ${{ matrix.node }}
# - name: Install pnpm
# run: |
# corepack enable
# corepack prepare
# - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
# with:
# path: ~/.pnpm-store
# key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
# - name: set store
# run: |
# pnpm config set store-dir ~/.pnpm-store
# - name: Install
# run: pnpm install --loglevel debug --ignore-scripts --registry http://localhost:4873
# - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
# with:
# path: ./packages/
# key: pkg-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
# # - uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # tag=v3
# # with:
# # path: ./e2e/
# # key: test-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
# - name: build e2e
# run: pnpm --filter @verdaccio/test-cli-commons build
# - name: Test CLI
# run: NODE_ENV=production pnpm test --filter ...@verdaccio/e2e-cli-${{matrix.pkg}}
e2e-cli-yarn:
needs: [prepare, build]
strategy:
fail-fast: false
matrix:
pkg:
[
yarn1,
yarn2,
yarn3,
yarn4
]
node: [20, 21]
name: ${{ matrix.pkg }}/ ubuntu-latest / ${{ matrix.node }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: ${{ matrix.node }}
- name: Install pnpm
run: |
corepack enable
corepack prepare
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: ~/.pnpm-store
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
- name: set store
run: |
pnpm config set store-dir ~/.pnpm-store
- name: Install
run: pnpm install --offline --reporter=silence --ignore-scripts --registry http://localhost:4873
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: ./packages/
key: pkg-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
# - uses: actions/cache@9b0c1fce7a93df8e3bb8926b0d6e9d89e92f20a7 # tag=v3
# with:
# path: ./e2e/
# key: test-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.run_id }}-${{ github.sha }}
- name: build e2e
run: pnpm --filter @verdaccio/test-cli-commons build
- name: Test CLI
run: NODE_ENV=production pnpm test --filter ...@verdaccio/e2e-cli-${{matrix.pkg}}

View file

@ -23,7 +23,7 @@ jobs:
- name: Install pnpm
run: |
corepack enable
corepack install
corepack prepare
- name: Install
run: pnpm install --reporter=silence --registry http://localhost:4873
- name: build

1
.npmrc
View file

@ -1,3 +1,2 @@
always-auth = true
loglevel=info
fetch-retries="10"

15
.prettierrc Normal file
View file

@ -0,0 +1,15 @@
{
"endOfLine": "lf",
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"bracketSpacing": true,
"trailingComma": "es5",
"semi": true,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^@verdaccio/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderParserPlugins": ["typescript", "classProperties", "jsx"],
"importOrderSortSpecifiers": true
}

View file

@ -1,13 +1,10 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.exclude": {
"**/.nyc_output": true,
"**/build": false,
"**/coverage": true,
".idea": true,
"storage_default_storage": true,
".yarn": true
},
"editor.formatOnSave": true,
"typescript.tsdk": "node_modules/typescript/lib"
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}

View file

@ -48,16 +48,15 @@ We use [corepack](https://github.com/nodejs/corepack) to install and use a speci
```shell
nvm install
corepack enable
corepack install
```
`pnpm` version will be updated mainly by the maintainers but if you would like to set it to a specific version, you can do so by running the following command:
```shell
corepack use pnpm@8.9.1
```
> `packageManager` at the `package.json` defines the default version to be used.
It will update the `package.json` file with the new version of pnpm in the `packageManager` field.
```shell
corepack prepare
```
With pnpm installed, the first step is installing all dependencies:

View file

@ -171,12 +171,6 @@
"local:publish": "cross-env npm_config_registry=http://localhost:4873 changeset publish --no-git-tag",
"local:publish:release": "concurrently \"pnpm local:registry\" \"pnpm local:publish\""
},
"pnpm": {
"overrides": {
"got": "11.8.5",
"p-cancelable": "2.1.1"
}
},
"engines": {
"node": ">=18"
},

View file

@ -55,7 +55,6 @@
"semver": "7.5.4"
},
"devDependencies": {
"@verdaccio/server": "workspace:7.0.0-next.4",
"@verdaccio/test-helper": "workspace:3.0.0-next.0",
"@verdaccio/types": "workspace:12.0.0-next.1",
"mockdate": "3.0.5",

View file

@ -45,11 +45,13 @@
"@verdaccio/signature": "workspace:7.0.0-next.2",
"@verdaccio/utils": "workspace:7.0.0-next.4",
"debug": "4.3.4",
"express": "4.18.2",
"lodash": "4.17.21",
"verdaccio-htpasswd": "workspace:12.0.0-next.4"
},
"devDependencies": {
"express": "4.18.2",
"supertest": "6.3.3",
"@verdaccio/middleware": "workspace:7.0.0-next.4",
"@verdaccio/types": "workspace:12.0.0-next.1"
},
"funding": {

View file

@ -1,5 +1,4 @@
import buildDebug from 'debug';
import { NextFunction, Request, Response } from 'express';
import _ from 'lodash';
import { HTPasswd } from 'verdaccio-htpasswd';
@ -12,22 +11,36 @@ import {
VerdaccioError,
errorUtils,
pluginUtils,
warningUtils,
} from '@verdaccio/core';
import '@verdaccio/core';
import { asyncLoadPlugin } from '@verdaccio/loaders';
import { logger } from '@verdaccio/logger';
import { aesEncrypt, parseBasicPayload, signPayload } from '@verdaccio/signature';
import {
aesEncrypt,
aesEncryptDeprecated,
parseBasicPayload,
signPayload,
} from '@verdaccio/signature';
import {
AllowAccess,
Callback,
Config,
JWTSignOptions,
Logger,
PackageAccess,
RemoteUser,
Security,
} from '@verdaccio/types';
import { getMatchedPackagesSpec, isFunction, isNil } from '@verdaccio/utils';
import {
$RequestExtend,
$ResponseExtend,
AESPayload,
IAuthMiddleware,
NextFunction,
TokenEncryption,
} from './types';
import {
convertPayloadToBase64,
getDefaultPlugins,
@ -40,25 +53,6 @@ import {
const debug = buildDebug('verdaccio:auth');
export interface TokenEncryption {
jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): Promise<string>;
aesEncrypt(buf: string): string | void;
}
// remove
export interface AESPayload {
user: string;
password: string;
}
export interface IAuthMiddleware {
apiJWTmiddleware(): $NextFunctionVer;
webUIJWTmiddleware(): $NextFunctionVer;
}
export type $RequestExtend = Request & { remote_user?: any; log: Logger };
export type $ResponseExtend = Response & { cookies?: any };
export type $NextFunctionVer = NextFunction & any;
class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
public config: Config;
public secret: string;
@ -75,6 +69,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
public async init() {
let plugins = (await this.loadPlugin()) as pluginUtils.Auth<unknown>[];
debug('auth plugins found %s', plugins.length);
if (!plugins || plugins.length === 0) {
plugins = this.loadDefaultPlugin();
@ -226,29 +221,32 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
debug('add user %o', user);
(function next(): void {
let method = 'adduser';
const plugin = plugins.shift() as pluginUtils.Auth<Config>;
if (typeof plugin.adduser !== 'function') {
// @ts-expect-error future major (7.x) should remove this section
if (typeof plugin.adduser === 'undefined' && typeof plugin.add_user === 'function') {
method = 'add_user';
warningUtils.emit(warningUtils.Codes.VERWAR006);
}
// @ts-ignore
if (typeof plugin[method] !== 'function') {
next();
} else {
// @ts-expect-error future major (7.x) should remove this section
if (typeof plugin.adduser === 'undefined' && typeof plugin.add_user === 'function') {
throw errorUtils.getInternalError(
'add_user method not longer supported, rename to adduser'
);
}
plugin.adduser(
// TODO: replace by adduser whenever add_user deprecation method has been removed
// @ts-ignore
plugin[method](
user,
password,
function (err: VerdaccioError | null, ok?: boolean | string): void {
if (err) {
debug('the user %o could not being added. Error: %o', user, err?.message);
debug('the user %o could not being added. Error: %o', user, err?.message);
return cb(err);
}
if (ok) {
debug('the user %o has been added', user);
return self.authenticate(user, password, cb);
}
debug('user could not be added, skip to next auth plugin');
next();
}
);
@ -375,7 +373,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
})();
}
public apiJWTmiddleware() {
public apiJWTmiddleware(): any {
debug('jwt middleware');
const plugins = this.plugins.slice(0);
const helpers = { createAnonymousRemoteUser, createRemoteUser };
@ -387,8 +385,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
return (req: $RequestExtend, res: $ResponseExtend, _next: NextFunction) => {
req.pause();
const next = function (err?: VerdaccioError): any {
const next = function (err?: VerdaccioError): NextFunction {
req.resume();
// uncomment this to reject users with bad auth headers
// return _next.apply(null, arguments)
@ -398,13 +395,14 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
req.remote_user.error = err.message;
}
return _next();
return _next() as unknown as NextFunction;
};
if (this._isRemoteUserValid(req.remote_user)) {
debug('jwt has a valid authentication header');
return next();
}
// FUTURE: disabled, not removed yet but seems unreacable code
// if (this._isRemoteUserValid(req.remote_user)) {
// debug('jwt has a valid authentication header');
// return next();
// }
// in case auth header does not exist we return anonymous function
const remoteUser = createAnonymousRemoteUser();
@ -425,20 +423,20 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
if (isAESLegacy(security)) {
debug('api middleware using legacy auth token');
this._handleAESMiddleware(req, security, secret, authorization, next);
this.handleAESMiddleware(req, security, secret, authorization, next);
} else {
debug('api middleware using JWT auth token');
this._handleJWTAPIMiddleware(req, security, secret, authorization, next);
this.handleJWTAPIMiddleware(req, security, secret, authorization, next);
}
};
}
private _handleJWTAPIMiddleware(
private handleJWTAPIMiddleware(
req: $RequestExtend,
security: Security,
secret: string,
authorization: string,
next: Function
next: any
): void {
debug('handle JWT api middleware');
const { scheme, token } = parseAuthTokenHeader(authorization);
@ -475,7 +473,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
}
}
private _handleAESMiddleware(
private handleAESMiddleware(
req: $RequestExtend,
security: Security,
secret: string,
@ -485,7 +483,12 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
debug('handle legacy api middleware');
debug('api middleware secret %o', typeof secret === 'string');
debug('api middleware authorization %o', typeof authorization === 'string');
const credentials: any = getMiddlewareCredentials(security, secret, authorization);
const credentials: any = getMiddlewareCredentials(
security,
secret,
authorization,
this.config?.getEnhancedLegacySignature()
);
debug('api middleware credentials %o', credentials?.name);
if (credentials) {
const { user, password } = credentials;
@ -515,7 +518,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
/**
* JWT middleware for WebUI
*/
public webUIJWTmiddleware(): $NextFunctionVer {
public webUIJWTmiddleware() {
return (req: $RequestExtend, res: $ResponseExtend, _next: NextFunction): void => {
if (this._isRemoteUserValid(req.remote_user)) {
return _next();
@ -525,7 +528,7 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
const next = (err: VerdaccioError | void): void => {
req.resume();
if (err) {
// req.remote_user.error = err.message;
req.remote_user.error = err.message;
res.status(err.statusCode).send(err.message);
}
@ -576,7 +579,6 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
name,
groups: groupedGroups,
};
const token: string = await signPayload(payload, this.secret, signOptions);
return token;
@ -586,7 +588,17 @@ class Auth implements IAuthMiddleware, TokenEncryption, pluginUtils.IBasicAuth {
* Encrypt a string.
*/
public aesEncrypt(value: string): string | void {
return aesEncrypt(value, this.secret);
// enhancedLegacySignature enables modern aes192 algorithm signature
if (this.config?.getEnhancedLegacySignature()) {
debug('signing with enhaced aes legacy');
const token = aesEncrypt(value, this.secret);
return token;
} else {
debug('signing with enhaced aes deprecated legacy');
// deprecated aes (legacy) signature, only must be used for legacy version
const token = aesEncryptDeprecated(Buffer.from(value), this.secret).toString('base64');
return token;
}
}
}

View file

@ -1,2 +1,3 @@
export { Auth } from './auth';
export * from './utils';
export * from './types';

View file

@ -0,0 +1,66 @@
import buildDebug from 'debug';
import _ from 'lodash';
import { TOKEN_BASIC, TOKEN_BEARER } from '@verdaccio/core';
import { aesDecryptDeprecated as aesDecrypt, parseBasicPayload } from '@verdaccio/signature';
import { Security } from '@verdaccio/types';
import { AuthMiddlewarePayload } from './types';
import {
convertPayloadToBase64,
isAESLegacy,
parseAuthTokenHeader,
verifyJWTPayload,
} from './utils';
const debug = buildDebug('verdaccio:auth:utils');
export function parseAESCredentials(authorizationHeader: string, secret: string) {
debug('parseAESCredentials');
const { scheme, token } = parseAuthTokenHeader(authorizationHeader);
// basic is deprecated and should not be enforced
// basic is currently being used for functional test
if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) {
debug('legacy header basic');
const credentials = convertPayloadToBase64(token).toString();
return credentials;
} else if (scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) {
debug('legacy header bearer');
const credentials = aesDecrypt(Buffer.from(token), secret);
return credentials;
}
}
export function getMiddlewareCredentials(
security: Security,
secretKey: string,
authorizationHeader: string
): AuthMiddlewarePayload {
debug('getMiddlewareCredentials');
// comment out for debugging purposes
if (isAESLegacy(security)) {
debug('is legacy');
const credentials = parseAESCredentials(authorizationHeader, secretKey);
if (typeof credentials !== 'string') {
debug('parse legacy credentials failed');
return;
}
const parsedCredentials = parseBasicPayload(credentials);
if (!parsedCredentials) {
debug('parse legacy basic payload credentials failed');
return;
}
return parsedCredentials;
}
const { scheme, token } = parseAuthTokenHeader(authorizationHeader);
debug('is jwt');
if (_.isString(token) && scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) {
return verifyJWTPayload(token, secretKey);
}
}

View file

@ -0,0 +1,66 @@
import buildDebug from 'debug';
import _ from 'lodash';
import { TOKEN_BASIC, TOKEN_BEARER } from '@verdaccio/core';
import { aesDecrypt, parseBasicPayload } from '@verdaccio/signature';
import { Security } from '@verdaccio/types';
import { AuthMiddlewarePayload } from './types';
import {
convertPayloadToBase64,
isAESLegacy,
parseAuthTokenHeader,
verifyJWTPayload,
} from './utils';
const debug = buildDebug('verdaccio:auth:utils');
export function parseAESCredentials(authorizationHeader: string, secret: string) {
debug('parseAESCredentials');
const { scheme, token } = parseAuthTokenHeader(authorizationHeader);
// basic is deprecated and should not be enforced
// basic is currently being used for functional test
if (scheme.toUpperCase() === TOKEN_BASIC.toUpperCase()) {
debug('legacy header basic');
const credentials = convertPayloadToBase64(token).toString();
return credentials;
} else if (scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) {
debug('legacy header bearer');
const credentials = aesDecrypt(token, secret);
return credentials;
}
}
export function getMiddlewareCredentials(
security: Security,
secretKey: string,
authorizationHeader: string
): AuthMiddlewarePayload {
debug('getMiddlewareCredentials');
// comment out for debugging purposes
if (isAESLegacy(security)) {
debug('is legacy');
const credentials = parseAESCredentials(authorizationHeader, secretKey);
if (!credentials) {
debug('parse legacy credentials failed');
return;
}
const parsedCredentials = parseBasicPayload(credentials);
if (!parsedCredentials) {
debug('parse legacy basic payload credentials failed');
return;
}
return parsedCredentials;
}
const { scheme, token } = parseAuthTokenHeader(authorizationHeader);
debug('is jwt');
if (_.isString(token) && scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) {
return verifyJWTPayload(token, secretKey);
}
}

View file

@ -0,0 +1,46 @@
import { NextFunction, Request, Response } from 'express';
import { VerdaccioError } from '@verdaccio/core';
import { AuthPackageAllow, JWTSignOptions, Logger, RemoteUser } from '@verdaccio/types';
export interface AESPayload {
user: string;
password: string;
}
export type BasicPayload = AESPayload | void;
export type AuthMiddlewarePayload = RemoteUser | BasicPayload;
export interface AuthTokenHeader {
scheme: string;
token: string;
}
export type AllowActionCallbackResponse = boolean | undefined;
export type AllowActionCallback = (
error: VerdaccioError | null,
allowed?: AllowActionCallbackResponse
) => void;
export type AllowAction = (
user: RemoteUser,
pkg: AuthPackageAllow,
callback: AllowActionCallback
) => void;
export interface TokenEncryption {
jwtEncrypt(user: RemoteUser, signOptions: JWTSignOptions): Promise<string>;
aesEncrypt(buf: string): string | void;
}
export type ActionsAllowed = 'publish' | 'unpublish' | 'access';
// remove
export interface IAuthMiddleware {
apiJWTmiddleware(): $NextFunctionVer;
webUIJWTmiddleware(): $NextFunctionVer;
}
export type $RequestExtend = Request & { remote_user?: any; log: Logger };
export type $ResponseExtend = Response & { cookies?: any };
export type $NextFunctionVer = NextFunction & any;
export { NextFunction };

View file

@ -7,36 +7,28 @@ import {
HTTP_STATUS,
TOKEN_BASIC,
TOKEN_BEARER,
VerdaccioError,
errorUtils,
pluginUtils,
} from '@verdaccio/core';
import { aesDecrypt, parseBasicPayload, verifyPayload } from '@verdaccio/signature';
import {
aesDecrypt,
aesDecryptDeprecated,
parseBasicPayload,
verifyPayload,
} from '@verdaccio/signature';
import { AuthPackageAllow, Config, Logger, RemoteUser, Security } from '@verdaccio/types';
import { AESPayload, TokenEncryption } from './auth';
import {
ActionsAllowed,
AllowAction,
AllowActionCallback,
AuthMiddlewarePayload,
AuthTokenHeader,
TokenEncryption,
} from './types';
const debug = buildDebug('verdaccio:auth:utils');
export type BasicPayload = AESPayload | void;
export type AuthMiddlewarePayload = RemoteUser | BasicPayload;
export interface AuthTokenHeader {
scheme: string;
token: string;
}
export type AllowActionCallbackResponse = boolean | undefined;
export type AllowActionCallback = (
error: VerdaccioError | null,
allowed?: AllowActionCallbackResponse
) => void;
export type AllowAction = (
user: RemoteUser,
pkg: AuthPackageAllow,
callback: AllowActionCallback
) => void;
/**
* Split authentication header eg: Bearer [secret_token]
* @param authorizationHeader auth token
@ -48,7 +40,11 @@ export function parseAuthTokenHeader(authorizationHeader: string): AuthTokenHead
return { scheme, token };
}
export function parseAESCredentials(authorizationHeader: string, secret: string) {
export function parseAESCredentials(
authorizationHeader: string,
secret: string,
enhanced: boolean
) {
debug('parseAESCredentials');
const { scheme, token } = parseAuthTokenHeader(authorizationHeader);
@ -61,7 +57,11 @@ export function parseAESCredentials(authorizationHeader: string, secret: string)
return credentials;
} else if (scheme.toUpperCase() === TOKEN_BEARER.toUpperCase()) {
debug('legacy header bearer');
const credentials = aesDecrypt(token, secret);
debug('legacy header enhanced?', enhanced);
const credentials = enhanced
? aesDecrypt(token.toString(), secret)
: // FUTURE: once deprecated legacy is removed this logic won't be longer need it
aesDecryptDeprecated(convertPayloadToBase64(token), secret).toString('utf-8');
return credentials;
}
@ -70,13 +70,14 @@ export function parseAESCredentials(authorizationHeader: string, secret: string)
export function getMiddlewareCredentials(
security: Security,
secretKey: string,
authorizationHeader: string
authorizationHeader: string,
enhanced: boolean = true
): AuthMiddlewarePayload {
debug('getMiddlewareCredentials');
// comment out for debugging purposes
if (isAESLegacy(security)) {
debug('is legacy');
const credentials = parseAESCredentials(authorizationHeader, secretKey);
const credentials = parseAESCredentials(authorizationHeader, secretKey, enhanced);
if (!credentials) {
debug('parse legacy credentials failed');
return;
@ -161,14 +162,15 @@ export function isAuthHeaderValid(authorization: string): boolean {
export function getDefaultPlugins(logger: Logger): pluginUtils.Auth<Config> {
return {
authenticate(_user: string, _password: string, cb: pluginUtils.AuthCallback): void {
debug('triggered default authenticate method');
cb(errorUtils.getForbidden(API_ERROR.BAD_USERNAME_PASSWORD));
},
adduser(_user: string, _password: string, cb: pluginUtils.AuthUserCallback): void {
debug('triggered default adduser method');
return cb(errorUtils.getConflict(API_ERROR.BAD_USERNAME_PASSWORD));
},
// FIXME: allow_action and allow_publish should be in the @verdaccio/types
// @ts-ignore
allow_access: allow_action('access', logger),
// @ts-ignore
@ -177,8 +179,6 @@ export function getDefaultPlugins(logger: Logger): pluginUtils.Auth<Config> {
};
}
export type ActionsAllowed = 'publish' | 'unpublish' | 'access';
export function allow_action(action: ActionsAllowed, logger: Logger): AllowAction {
return function allowActionCallback(
user: RemoteUser,
@ -187,8 +187,13 @@ export function allow_action(action: ActionsAllowed, logger: Logger): AllowActio
): void {
logger.trace({ remote: user.name }, `[auth/allow_action]: user: @{remote}`);
const { name, groups } = user;
debug('allow_action "%s": groups %s', action, groups);
const groupAccess = pkg[action] as string[];
const hasPermission = groupAccess.some((group) => name === group || groups.includes(group));
debug('allow_action "%s": groupAccess %s', action, groupAccess);
const hasPermission = groupAccess.some((group) => {
return name === group || groups.includes(group);
});
debug('package "%s" has permission "%s"', name, hasPermission);
logger.trace(
{ pkgName: pkg.name, hasPermission, remote: user.name, groupAccess },
`[auth/allow_action]: hasPermission? @{hasPermission} for user: @{remote}, package: @{pkgName}`
@ -218,7 +223,8 @@ export function handleSpecialUnpublish(logger: Logger): any {
return function (user: RemoteUser, pkg: AuthPackageAllow, callback: AllowActionCallback): void {
const action = 'unpublish';
// verify whether the unpublish prop has been defined
const isUnpublishMissing: boolean = _.isNil(pkg[action]);
const isUnpublishMissing: boolean = !pkg[action];
debug('is unpublish method missing ? %s', isUnpublishMissing);
const hasGroups: boolean = isUnpublishMissing ? false : (pkg[action] as string[]).length > 0;
logger.trace(
{ user: user.name, name: pkg.name, hasGroups },

View file

@ -1,47 +1,79 @@
import express from 'express';
import path from 'path';
import supertest from 'supertest';
import { Config as AppConfig, ROLES, getDefaultConfig } from '@verdaccio/config';
import { errorUtils } from '@verdaccio/core';
import { setup } from '@verdaccio/logger';
import { Config as AppConfig, ROLES, createRemoteUser, getDefaultConfig } from '@verdaccio/config';
import {
API_ERROR,
HEADERS,
HTTP_STATUS,
SUPPORT_ERRORS,
TOKEN_BEARER,
errorUtils,
} from '@verdaccio/core';
import { logger, setup } from '@verdaccio/logger';
import { errorReportingMiddleware, final, handleError } from '@verdaccio/middleware';
import { Config } from '@verdaccio/types';
import { buildToken } from '@verdaccio/utils';
import { Auth } from '../src';
import { authPluginFailureConf, authPluginPassThrougConf, authProfileConf } from './helper/plugin';
import { $RequestExtend, Auth } from '../src';
import {
authChangePasswordConf,
authPluginFailureConf,
authPluginPassThrougConf,
authProfileConf,
} from './helper/plugin';
setup({ level: 'debug', type: 'stdout' });
setup({});
// to avoid flaky test generate same ramdom key
jest.mock('@verdaccio/utils', () => {
return {
...jest.requireActual('@verdaccio/utils'),
// used by enhanced legacy aes signature (minimum 32 characters)
generateRandomSecretKey: () => 'GCYW/3IJzQI6GvPmy9sbMkFoiL7QLVw',
// used by legacy aes signature
generateRandomHexString: () =>
'ff065fcf7a8330ae37d3ea116328852f387ad7aa6defbe47fb68b1ea25f97446',
};
});
describe('AuthTest', () => {
test('should init correctly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
describe('default', () => {
test('should init correctly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
test('should load default auth plugin', async () => {
const config: Config = new AppConfig({ ...authProfileConf, auth: undefined });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
test('should load custom algorithm', async () => {
const config: Config = new AppConfig({
...authProfileConf,
auth: { htpasswd: { algorithm: 'sha1', file: './foo' } },
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
test('should load default auth plugin', async () => {
const config: Config = new AppConfig({ ...authProfileConf, auth: undefined });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
});
});
describe('test authenticate method', () => {
describe('utils', () => {
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('authenticate', () => {
describe('test authenticate states', () => {
test('should be a success login', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
@ -163,30 +195,519 @@ describe('AuthTest', () => {
}
});
});
describe('test multiple authenticate methods', () => {
test('should skip falsy values', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
success: {},
'fail-invalid-method': {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
return new Promise((resolve) => {
auth.authenticate('foo', 'bar', (err, value) => {
expect(value).toEqual({
name: 'foo',
groups: ['test', ROLES.$ALL, '$authenticated', '@all', '@authenticated', 'all'],
real_groups: ['test'],
});
resolve(value);
});
});
});
});
});
describe('test multiple authenticate methods', () => {
test('should skip falsy values', async () => {
describe('changePassword', () => {
test('should fail if the plugin does not provide implementation', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.changePassword('foo', 'bar', 'newFoo', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getInternalError(SUPPORT_ERRORS.PLUGIN_MISSING_INTERFACE)
);
});
test('should handle plugin does provide implementation', async () => {
const config: Config = new AppConfig({ ...authChangePasswordConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('foo', 'bar', jest.fn());
auth.changePassword('foo', 'bar', 'newFoo', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
describe('allow_access', () => {
describe('no custom allow_access implementation provided', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_access(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to access package foo')
);
});
test('should success if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$ALL];
auth.allow_access(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('allow_publish', () => {
describe('no custom allow_publish implementation provided', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_publish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to publish package foo')
);
});
test('should success if groups do match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$AUTH];
auth.allow_publish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('allow_unpublish', () => {
describe('no custom allow_unpublish implementation provided', () => {
test('should fails if groups do not match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to unpublish package foo')
);
});
test('should handle missing unpublish method (special case to handle legacy configurations)', async () => {
const config: Config = new AppConfig({
...authProfileConf,
packages: {
...authProfileConf.packages,
'**': {
access: ['$all'],
publish: ['$authenticated'],
// it forces publish handle the access
unpublish: undefined,
proxy: ['npmjs'],
},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
const groups = ['test'];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getForbidden('user foo is not allowed to publish package foo')
);
});
test('should success if groups do match exactly', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// $all comes from configuration file
const groups = [ROLES.$AUTH];
auth.allow_unpublish(
{ packageName: 'foo' },
{ name: 'foo', groups, real_groups: groups },
callback
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, true);
});
});
});
describe('add_user', () => {
describe('error handling', () => {
// when allow_access is not implemented, the groups must match
// exactly with the packages access group
test('should fails with bad password if adduser is not implemented', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('juan', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getConflict(API_ERROR.BAD_USERNAME_PASSWORD)
);
});
test('should fails if adduser fails internally (exception)', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
adduser: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// note: fail uas username make plugin fails
auth.add_user('fail', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(new Error('bad username'));
});
test('should skip to the next plugin and fails', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
adduser: {},
// plugin implement adduser with fail auth
fail: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
// note: fail uas username make plugin fails
auth.add_user('skip', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
errorUtils.getConflict(API_ERROR.BAD_USERNAME_PASSWORD)
);
});
});
test('should success if adduser', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
success: {},
'fail-invalid-method': {},
adduser: {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
return new Promise((resolve) => {
auth.authenticate('foo', 'bar', (err, value) => {
expect(value).toEqual({
name: 'foo',
groups: ['test', '$all', '$authenticated', '@all', '@authenticated', 'all'],
real_groups: ['test'],
const callback = jest.fn();
auth.add_user('something', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: ['test', '$all', '$authenticated', '@all', '@authenticated', 'all'],
name: 'something',
real_groups: ['test'],
});
});
test('should handle legacy add_user method', async () => {
const config: Config = new AppConfig({
...getDefaultConfig(),
plugins: path.join(__dirname, './partials/plugin'),
auth: {
'adduser-legacy': {},
},
});
config.checkSecretKey('12345');
const auth: Auth = new Auth(config);
await auth.init();
expect(auth).toBeDefined();
const callback = jest.fn();
auth.add_user('something', 'password', callback);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(null, {
groups: ['test', '$all', '$authenticated', '@all', '@authenticated', 'all'],
name: 'something',
real_groups: ['test'],
});
});
});
describe('middlewares', () => {
describe('apiJWTmiddleware', () => {
const secret = '12345';
const getServer = async function (auth) {
const app = express();
app.use(express.json({ strict: false, limit: '10mb' }));
// @ts-expect-error
app.use(errorReportingMiddleware(logger));
app.use(auth.apiJWTmiddleware());
app.get('/*', (req, res, next) => {
if ((req as $RequestExtend).remote_user.error) {
next(new Error((req as $RequestExtend).remote_user.error));
} else {
// @ts-expect-error
next({ user: req?.remote_user });
}
});
// @ts-expect-error
app.use(handleError(logger));
// @ts-expect-error
app.use(final);
return app;
};
describe('legacy signature', () => {
describe('error cases', () => {
test('should handle invalid auth token', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
return supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, 'Bearer foo')
.expect(HTTP_STATUS.INTERNAL_ERROR);
});
test('should handle missing auth header', async () => {
const config: Config = new AppConfig({ ...authProfileConf });
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
return supertest(app).get(`/`).expect(HTTP_STATUS.OK);
});
});
describe('deprecated legacy handling forceEnhancedLegacySignature=false', () => {
test('should handle valid auth token', async () => {
const payload = 'juan:password';
// const token = await signPayload(remoteUser, '12345');
const config: Config = new AppConfig(
{ ...authProfileConf },
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = auth.aesEncrypt(payload) as string;
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.OK);
expect(res.body.user.name).toEqual('juan');
});
test('should handle invalid auth token', async () => {
const payload = 'juan:password';
const config: Config = new AppConfig(
{ ...authPluginFailureConf },
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = auth.aesEncrypt(payload) as string;
const app = await getServer(auth);
return await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.INTERNAL_ERROR);
});
});
});
describe('jwt signature', () => {
describe('error cases', () => {
test('should handle invalid auth token and return anonymous', async () => {
// @ts-expect-error
const config: Config = new AppConfig({
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
});
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, 'Bearer foo')
.expect(HTTP_STATUS.OK);
expect(res.body.user.groups).toEqual([
ROLES.$ALL,
ROLES.$ANONYMOUS,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_ANONYMOUS,
]);
});
test('should handle missing auth header', async () => {
// @ts-expect-error
const config: Config = new AppConfig({
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
});
config.checkSecretKey(secret);
const auth = new Auth(config);
await auth.init();
const app = await getServer(auth);
const res = await supertest(app).get(`/`).expect(HTTP_STATUS.OK);
expect(res.body.user.groups).toEqual([
ROLES.$ALL,
ROLES.$ANONYMOUS,
ROLES.DEPRECATED_ALL,
ROLES.DEPRECATED_ANONYMOUS,
]);
});
});
describe('valid signature handlers', () => {
test('should handle valid auth token', async () => {
const config: Config = new AppConfig(
// @ts-expect-error
{
...authProfileConf,
...{ security: { api: { jwt: { sign: { expiresIn: '29d' } } } } },
},
{ forceEnhancedLegacySignature: false }
);
// intended to force key generator (associated with mocks above)
config.checkSecretKey(undefined);
const auth = new Auth(config);
await auth.init();
const token = (await auth.jwtEncrypt(
createRemoteUser('jwt_user', [ROLES.ALL]),
// @ts-expect-error
config.security.api.jwt.sign
)) as string;
const app = await getServer(auth);
const res = await supertest(app)
.get(`/`)
.set(HEADERS.AUTHORIZATION, buildToken(TOKEN_BEARER, token))
.expect(HTTP_STATUS.OK);
expect(res.body.user.name).toEqual('jwt_user');
});
resolve(value);
});
});
});

View file

@ -10,6 +10,14 @@ export const authProfileConf = {
},
};
export const authChangePasswordConf = {
...getDefaultConfig(),
plugins: path.join(__dirname, '../partials/plugin'),
auth: {
'change-password': {},
},
};
export const authPluginFailureConf = {
...getDefaultConfig(),
plugins: path.join(__dirname, '../partials/plugin'),

View file

@ -0,0 +1,9 @@
module.exports = function () {
return {
authenticate(user, pass, callback) {
// https://verdaccio.org/docs/en/dev-plugins#onsuccess
// this is a successful login and return a simple group
callback(null, ['test']);
},
};
};

View file

@ -0,0 +1,5 @@
{
"name": "verdaccio-access-ok",
"main": "access.js",
"version": "1.0.0"
}

View file

@ -0,0 +1,25 @@
module.exports = function () {
return {
authenticate(user, pass, callback) {
// https://verdaccio.org/docs/en/dev-plugins#onsuccess
// this is a successful login and return a simple group
callback(null, ['test']);
},
add_user(user, password, cb) {
if (user === 'fail') {
return cb(Error('bad username'));
}
if (user === 'password') {
return cb(Error('bad password'));
}
if (user === 'skip') {
// if wants to the next plugin
return cb(null, false);
}
cb(null, true);
},
};
};

View file

@ -0,0 +1,5 @@
{
"name": "verdaccio-adduser-legacy",
"main": "adduser.js",
"version": "1.0.0"
}

View file

@ -0,0 +1,25 @@
module.exports = function () {
return {
authenticate(user, pass, callback) {
// https://verdaccio.org/docs/en/dev-plugins#onsuccess
// this is a successful login and return a simple group
callback(null, ['test']);
},
adduser(user, password, cb) {
if (user === 'fail') {
return cb(Error('bad username'));
}
if (user === 'password') {
return cb(Error('bad password'));
}
if (user === 'skip') {
// if wants to the next plugin
return cb(null, false);
}
cb(null, true);
},
};
};

View file

@ -0,0 +1,5 @@
{
"name": "verdaccio-adduser",
"main": "adduser.js",
"version": "1.0.0"
}

View file

@ -0,0 +1,32 @@
module.exports = function () {
return {
users: [],
authenticate(user, pass, callback) {
// https://verdaccio.org/docs/en/dev-plugins#onsuccess
// this is a successful login and return a simple group
callback(null, ['test']);
},
changePassword(user, password, newPassword, cb) {
if (password === newPassword) {
return cb(Error('error password equal'));
}
return cb(null, true);
},
adduser(user, password, cb) {
if (user === 'fail') {
return cb(Error('bad username'));
}
if (user === 'password') {
return cb(Error('bad password'));
}
if (user === 'skip') {
// if wants to the next plugin
return cb(null, false);
}
cb(null, true);
},
};
};

View file

@ -0,0 +1,5 @@
{
"name": "verdaccio-change-password",
"main": "change.js",
"version": "1.0.0"
}

View file

@ -7,5 +7,8 @@ module.exports = function () {
and success types respectively for testing purposes */
callback(errorUtils.getInternalError(), false);
},
adduser(user, password, cb) {
return cb(null, false);
},
};
};

View file

@ -63,6 +63,10 @@ class Config implements AppConfig {
private configOptions: { forceEnhancedLegacySignature: boolean };
public constructor(
config: ConfigYaml & { config_path: string },
// forceEnhancedLegacySignature is a property that
// allows switch a new legacy aes signature token signature
// for older versions do not want to have this new signature model
// this property must be false
configOptions = { forceEnhancedLegacySignature: true }
) {
const self = this;
@ -131,6 +135,16 @@ class Config implements AppConfig {
}
}
public getEnhancedLegacySignature() {
if (typeof this?.security.enhancedLegacySignature !== 'undefined') {
if (this.security.enhancedLegacySignature === true) {
return true;
}
return false;
}
return this.configOptions.forceEnhancedLegacySignature;
}
public getConfigPath() {
return this.configPath;
}
@ -156,24 +170,23 @@ class Config implements AppConfig {
}
// generate a new a secret key
// FUTURE: this might be an external secret key, perhaps within config file?
debug('generate a new key');
//
if (this.configOptions.forceEnhancedLegacySignature) {
debug('generating a new secret key');
if (this.getEnhancedLegacySignature()) {
debug('key generated with "enhanced" legacy signature user config');
this.secret = generateRandomSecretKey();
} else {
this.secret =
this.security.enhancedLegacySignature === true
? generateRandomSecretKey()
: generateRandomHexString(32);
// set this to false allow use old token signature and is not recommended
// only use for migration reasons, major release will remove this property and
// set it by default
if (this.security.enhancedLegacySignature === false) {
warningUtils.emit(Codes.VERWAR005);
}
debug('key generated with legacy signature user config');
this.secret = generateRandomHexString(32);
}
// set this to false allow use old token signature and is not recommended
// only use for migration reasons, major release will remove this property and
// set it by default
if (this.security?.enhancedLegacySignature === false) {
warningUtils.emit(Codes.VERWAR005);
}
debug('generated a new secret key %s', this.secret?.length);
debug('generated a new secret key length %s', this.secret?.length);
return this.secret;
}
}

View file

@ -12,6 +12,7 @@ export enum Codes {
VERWAR005 = 'VERWAR005',
// deprecation warnings
VERDEP003 = 'VERDEP003',
VERWAR006 = 'VERWAR006',
}
warningInstance.create(
@ -52,6 +53,12 @@ warningInstance.create(
'multiple addresses will be deprecated in the next major, only use one'
);
warningInstance.create(
verdaccioDeprecation,
Codes.VERWAR006,
'the auth plugin method "add_user" in the auth plugin is deprecated and will be removed in next major release, rename to "adduser"'
);
export function emit(code: string, a?: string, b?: string, c?: string) {
warningInstance.emit(code, a, b, c);
}

View file

@ -296,7 +296,7 @@ export interface Config extends Omit<ConfigYaml, 'packages' | 'security' | 'conf
// security object defaults is added by the config file but optional in the yaml file
security: Security;
// @deprecated (pending adding the replacement)
checkSecretKey(token: string): string;
checkSecretKey(token: string | void): string;
getMatchedPackagesSpec(storage: string): PackageAccess | void;
// TODO: verify how to handle this in the future
[key: string]: any;

View file

@ -1,6 +1,6 @@
import buildDebug from 'debug';
import { URL } from 'url';
import isURLValidator from 'validator/lib/isURL';
import validator from 'validator';
import { HEADERS } from '@verdaccio/core';
@ -17,7 +17,7 @@ export function isURLhasValidProtocol(uri: string): boolean {
}
export function isHost(url: string = '', options = {}): boolean {
return isURLValidator(url, {
return validator.isURL(url, {
require_host: true,
allow_trailing_dot: false,
require_valid_protocol: false,
@ -130,3 +130,5 @@ export function getPublicUrl(url_prefix: string = '', requestOptions: RequestOpt
return '/';
}
}
export const isURL = validator.isURL;

View file

@ -43,7 +43,7 @@ export const errorReportingMiddleware = (logger) =>
res: $ResponseExtend,
next: $NextFunctionVer
): void {
debug('error report middleware');
debug('error report middleware start');
res.locals.report_error =
res.locals.report_error ||
function (err: VerdaccioError): void {
@ -64,7 +64,7 @@ export const errorReportingMiddleware = (logger) =>
debug('this is an error in express.js, please report this, destroy response %o', err);
res.destroy();
} else if (!res.headersSent) {
debug('report internal error %o', err);
debug('send internal error %o', err);
res.status(HTTP_STATUS.INTERNAL_ERROR);
next({ error: API_ERROR.INTERNAL_SERVER_ERROR });
} else {
@ -74,6 +74,6 @@ export const errorReportingMiddleware = (logger) =>
}
};
debug('error report middleware next()');
debug('error report middleware end (skip next layer) next()');
next();
};

View file

@ -1,3 +1,4 @@
import buildDebug from 'debug';
import _ from 'lodash';
import { HEADERS, HTTP_STATUS, TOKEN_BASIC, TOKEN_BEARER } from '@verdaccio/core';
@ -8,6 +9,8 @@ import { $NextFunctionVer, $RequestExtend, $ResponseExtend, MiddlewareError } fr
export type FinalBody = Manifest | MiddlewareError | string;
const debug = buildDebug('verdaccio:middleware:final');
export function final(
body: FinalBody,
req: $RequestExtend,
@ -17,17 +20,20 @@ export function final(
next: $NextFunctionVer
): void {
if (res.statusCode === HTTP_STATUS.UNAUTHORIZED && !res.getHeader(HEADERS.WWW_AUTH)) {
debug('set auth header support');
res.header(HEADERS.WWW_AUTH, `${TOKEN_BASIC}, ${TOKEN_BEARER}`);
}
try {
if (_.isString(body) || _.isObject(body)) {
if (!res.get(HEADERS.CONTENT_TYPE)) {
debug('set json type header support');
res.header(HEADERS.CONTENT_TYPE, HEADERS.JSON);
}
if (typeof body === 'object' && _.isNil(body) === false) {
if (typeof (body as MiddlewareError).error === 'string') {
debug('set verdaccio_error method');
res.locals._verdaccio_error = (body as MiddlewareError).error;
}
body = JSON.stringify(body, undefined, ' ') + '\n';
@ -38,9 +44,12 @@ export function final(
!res.statusCode ||
(res.statusCode >= HTTP_STATUS.OK && res.statusCode < HTTP_STATUS.MULTIPLE_CHOICES)
) {
res.header(HEADERS.ETAG, '"' + stringToMD5(body as string) + '"');
const etag = stringToMD5(body as string);
debug('set etag header %s', etag);
res.header(HEADERS.ETAG, '"' + etag + '"');
}
} else {
debug('this line should never be visible, if does report');
// send(null), send(204), etc.
}
} catch (err: any) {
@ -48,7 +57,9 @@ export function final(
// as an error handler, we can't report error properly,
// and should just close socket
if (err.message.match(/set headers after they are sent/)) {
debug('set headers after they are sent');
if (_.isNil(res.socket) === false) {
debug('force destroy socket');
res.socket?.destroy();
}
return;

View file

@ -7,7 +7,7 @@ export type Manifest = {
js: string[];
};
const debug = buildDebug('verdaccio:web:render:manifest');
const debug = buildDebug('verdaccio:middleware:web:render:manifest');
export function getManifestValue(
manifestItems: string[],

View file

@ -2,17 +2,23 @@ import compression from 'compression';
import cors from 'cors';
import buildDebug from 'debug';
import express from 'express';
import { HttpError } from 'http-errors';
import _ from 'lodash';
import AuditMiddleware from 'verdaccio-audit';
import apiEndpoint from '@verdaccio/api';
import { Auth } from '@verdaccio/auth';
import { Config as AppConfig } from '@verdaccio/config';
import { API_ERROR, HTTP_STATUS, errorUtils, pluginUtils } from '@verdaccio/core';
import { API_ERROR, errorUtils, pluginUtils } from '@verdaccio/core';
import { asyncLoadPlugin } from '@verdaccio/loaders';
import { logger } from '@verdaccio/logger';
import { errorReportingMiddleware, final, log, rateLimit, userAgent } from '@verdaccio/middleware';
import {
errorReportingMiddleware,
final,
handleError,
log,
rateLimit,
userAgent,
} from '@verdaccio/middleware';
import { Storage } from '@verdaccio/store';
import { ConfigYaml } from '@verdaccio/types';
import { Config as IConfig } from '@verdaccio/types';
@ -102,27 +108,30 @@ const defineAPI = async function (config: IConfig, storage: Storage): Promise<an
next(errorUtils.getNotFound('resource not found'));
});
app.use(function (
err: HttpError,
req: $RequestExtend,
res: $ResponseExtend,
next: $NextFunctionVer
) {
if (_.isError(err)) {
if (err.code === 'ECONNABORT' && res.statusCode === HTTP_STATUS.NOT_MODIFIED) {
return next();
}
if (_.isFunction(res.locals.report_error) === false) {
// in case of very early error this middleware may not be loaded before error is generated
// fixing that
errorReportingMiddlewareWrap(req, res, _.noop);
}
res.locals.report_error(err);
} else {
// Fall to Middleware.final
return next(err);
}
});
// @ts-ignore
app.use(handleError(logger));
// app.use(function (
// err: HttpError,
// req: $RequestExtend,
// res: $ResponseExtend,
// next: $NextFunctionVer
// ) {
// if (_.isError(err)) {
// if (err.code === 'ECONNABORT' && res.statusCode === HTTP_STATUS.NOT_MODIFIED) {
// return next();
// }
// if (_.isFunction(res.locals.report_error) === false) {
// // in case of very early error this middleware may not be loaded before error is generated
// // fixing that
// errorReportingMiddlewareWrap(req, res, _.noop);
// }
// res.locals.report_error(err);
// } else {
// // Fall to Middleware.final
// return next(err);
// }
// });
app.use(final);

View file

@ -8,7 +8,7 @@ import { generateRandomHexString } from '@verdaccio/utils';
import apiMiddleware from '../src';
setup();
setup({});
export const getConf = async (conf) => {
const configPath = path.join(__dirname, 'config', conf);

View file

@ -39,8 +39,7 @@
},
"dependencies": {
"jsonwebtoken": "9.0.2",
"debug": "4.3.4",
"lodash": "4.17.21"
"debug": "4.3.4"
},
"devDependencies": {
"@verdaccio/config": "workspace:7.0.0-next.4",

View file

@ -41,6 +41,6 @@ export async function signPayload(
}
export function verifyPayload(token: string, secretOrPrivateKey: string): RemoteUser {
debug('verify jwt token');
debug('verifying jwt token');
return jwt.verify(token, secretOrPrivateKey) as RemoteUser;
}

View file

@ -24,7 +24,7 @@ export function aesEncrypt(value: string, key: string): string | void {
debug('encrypt %o', value);
debug('algorithm %o', defaultAlgorithm);
// IV must be a buffer of length 16
const iv = Buffer.from(randomBytes(16));
const iv = randomBytes(16);
const secretKey = VERDACCIO_LEGACY_ENCRYPTION_KEY || key;
const isKeyValid = secretKey?.length === TOKEN_VALID_LENGTH;
debug('length secret key %o', secretKey?.length);

View file

@ -18,6 +18,8 @@ export async function initializeServer(
Storage
): Promise<Application> {
const app = express();
// verdaccio next always uses forceEnhancedLegacySignature while legacy (5.x, 6.x)
// have this property false by default
const config = new Config(configName, { forceEnhancedLegacySignature: true });
config.storage = path.join(os.tmpdir(), '/storage', generateRandomHexString());
// httpass would get path.basename() for configPath thus we need to create a dummy folder

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
module.exports = {
endOfLine: 'lf',
useTabs: false,
printWidth: 100,
tabWidth: 2,
singleQuote: true,
bracketSpacing: true,
trailingComma: 'es5',
semi: true,
plugins: [require('@trivago/prettier-plugin-sort-imports')],
importOrder: ['^@verdaccio/(.*)$', '^[./]'],
importOrderSeparation: true,
importOrderParserPlugins: ['typescript', 'classProperties', 'jsx'],
importOrderSortSpecifiers: true,
};

View file

@ -84,9 +84,11 @@ auth:
max_users: 1000
```
### Security {#security}
### Token signature {#token}
<small>Since: `verdaccio@4.0.0` [#168](https://github.com/verdaccio/verdaccio/pull/168)</small>
The default token signature is based on the Advanced Encryption Standard (AES) with the algorithm `aes192`, known as _legacy_. It's important to note that legacy tokens are not designed to expire. If expiration functionality is needed, it is recommended to use _JSON Web Tokens (JWT)_ instead.
#### Security {#security}
The security block allows you to customise the token signature. To enable a new [JWT (JSON Web Tokens)](https://jwt.io/) signature you need to add the block `jwt` to the `api` section; `web` uses `jwt` by default.
@ -94,6 +96,7 @@ The configuration is separated in two sections, `api` and `web`. To use JWT on `
```
security:
enhancedLegacySignature: false
api:
legacy: true
jwt:
@ -108,7 +111,34 @@ security:
someProp: [value]
```
> We highly recommend move to JWT since legacy signature (`aes192`) is deprecated and will disappear in future versions.
#### `enhancedLegacySignature` {#enhancedLegacySignature}
In certain instances, particularly in older installations, you might encounter the warning `[DEP0106] DeprecationWarning: crypto.createDecipher is deprecated`. in your terminal. This warning indicates that Node.js has deprecated a function utilized by the legacy signature. To address this, you can enable the enhancedLegacySignature property, which switches the legacy token signature to one based on AES-192 with an initialization vector.
:::caution
It is crucial to emphasize that enabling this option will lead to the invalidation of previous tokens.
In v6.x and older versions, the property `enhancedLegacySignature` is set to `false` by default upon initialization, to change that behaviour follow as illustrated in the following example.
:::caution
```
security:
enhancedLegacySignature: true
api:
legacy: true
jwt:
sign:
expiresIn: 29d
verify:
someProp: [value]
web:
sign:
expiresIn: 1h # 1 hour by default
verify:
someProp: [value]
```
### Server {#server}

View file

@ -409,11 +409,11 @@ module.exports = {
return `https://github.com/verdaccio/verdaccio/edit/master/website/docs/${docPath}`;
},
lastVersion: '5.x',
onlyIncludeVersions: ['5.x', '6.x'],
// onlyIncludeVersions: ['next', '5.x', '6.x'],
versions: {
current: {
label: `5.x`,
},
// current: {
// label: `next`,
// },
'6.x': {
label: `6.x`,
banner: 'unreleased',

View file

@ -61,11 +61,6 @@
"use-is-in-viewport": "^1.0.9",
"usehooks-ts": "2.9.1"
},
"pnpm": {
"overrides": {
"@mdx-js/react": "^1.6.22"
}
},
"browserslist": {
"production": [
">0.5%",

View file

@ -86,8 +86,6 @@ auth:
### Security {#security}
<small>Since: `verdaccio@4.0.0` [#168](https://github.com/verdaccio/verdaccio/pull/168)</small>
The security block allows you to customise the token signature. To enable a new [JWT (JSON Web Tokens)](https://jwt.io/) signature you need to add the block `jwt` to the `api` section; `web` uses `jwt` by default.
The configuration is separated in two sections, `api` and `web`. To use JWT on `api` it has to be defined, otherwise the legacy token signature (`aes192`) will be used. For JWT you might want to 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.
@ -108,7 +106,11 @@ security:
someProp: [value]
```
> We highly recommend move to JWT since legacy signature (`aes192`) is deprecated and will disappear in future versions.
:::info
In 5.x versions, you will see the warning `[DEP0106] DeprecationWarning: crypto.createDecipher is deprecated`. in your terminal. This warning indicates that Node.js has deprecated a function utilized by the legacy signature. **To address thi please upgrade to the newest 6.x version (to check the release status read here [available](https://github.com/verdaccio/verdaccio/discussions/4018)).**
:::info
### Server {#server}

View file

@ -84,9 +84,11 @@ auth:
max_users: 1000
```
### Security {#security}
### Token signature {#token}
<small>Since: `verdaccio@4.0.0` [#168](https://github.com/verdaccio/verdaccio/pull/168)</small>
The default token signature is based on the Advanced Encryption Standard (AES) with the algorithm `aes192`, known as _legacy_. It's important to note that legacy tokens are not designed to expire. If expiration functionality is needed, it is recommended to use _JSON Web Tokens (JWT)_ instead.
#### Security {#security}
The security block allows you to customise the token signature. To enable a new [JWT (JSON Web Tokens)](https://jwt.io/) signature you need to add the block `jwt` to the `api` section; `web` uses `jwt` by default.
@ -94,6 +96,7 @@ The configuration is separated in two sections, `api` and `web`. To use JWT on `
```
security:
enhancedLegacySignature: false
api:
legacy: true
jwt:
@ -108,7 +111,34 @@ security:
someProp: [value]
```
> We highly recommend move to JWT since legacy signature (`aes192`) is deprecated and will disappear in future versions.
#### `enhancedLegacySignature` {#enhancedLegacySignature}
In certain instances, particularly in older installations, you might encounter the warning `[DEP0106] DeprecationWarning: crypto.createDecipher is deprecated`. in your terminal. This warning indicates that Node.js has deprecated a function utilized by the legacy signature. To address this, you can enable the `enhancedLegacySignature` property, which switches the legacy token signature to one based on AES-192 with an initialization vector.
:::caution
It is crucial to emphasize that enabling this option will lead to the invalidation of previous tokens.
For all 6.x versions, the property `enhancedLegacySignature` is set to `false` by default upon initialization, to change that behaviour follow as illustrated in the following example.
:::caution
```
security:
enhancedLegacySignature: true
api:
legacy: true
jwt:
sign:
expiresIn: 29d
verify:
someProp: [value]
web:
sign:
expiresIn: 1h # 1 hour by default
verify:
someProp: [value]
```
### Server {#server}