1
0
mirror of https://github.com/verdaccio/verdaccio.git synced 2024-11-08 23:25:51 +01:00

feat: add support for profile cli command #392 (change password) (#1034)

* feat: add support for profile cli command #392

- it allows to update password npm profile set password
- display current profile npm profile get

https://docs.npmjs.com/cli/profile

* chore: update @verdaccio/types@4.0.0

* feat: add min password length

on npm by defaul is min 7 characters, this might be configurable in the future.

* chore: update verdaccio-htpasswd@1.0.1

* refactor: update unit test

* refactor: provide friendly error for tfa request

* test: api profile unit test

* chore: fix eslint comment

* test: update profile test

* chore: set mim as 3 characters
This commit is contained in:
Juan Picado @jotadeveloper 2018-10-12 11:07:55 +02:00 committed by GitHub
parent 87092a5185
commit f1416ed557
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 396 additions and 62 deletions

4
.gitignore vendored

@ -5,7 +5,9 @@ build/
### Test
test/unit/partials/store/test-jwt-storage/*
test/unit/partials/store/test-*-storage/*
.verdaccio-db.json
.sinopia-db.json
###
!bin/verdaccio

@ -48,7 +48,7 @@
"request": "2.88.0",
"semver": "5.5.1",
"verdaccio-audit": "0.2.0",
"verdaccio-htpasswd": "0.2.2",
"verdaccio-htpasswd": "1.0.1",
"verror": "1.10.0"
},
"devDependencies": {
@ -56,7 +56,7 @@
"@commitlint/config-conventional": "7.1.2",
"@material-ui/core": "3.1.0",
"@material-ui/icons": "3.0.1",
"@verdaccio/types": "3.7.2",
"@verdaccio/types": "4.0.0",
"babel-cli": "6.26.0",
"babel-core": "6.26.3",
"babel-eslint": "10.0.0",

@ -7,8 +7,8 @@ import _ from 'lodash';
import Cookies from 'cookies';
import { ErrorCode } from '../../../lib/utils';
import { API_MESSAGE, HTTP_STATUS } from '../../../lib/constants';
import { createSessionToken, getApiToken, getAuthenticatedMessage } from '../../../lib/auth-utils';
import { API_ERROR, API_MESSAGE, HTTP_STATUS } from '../../../lib/constants';
import { createSessionToken, getApiToken, getAuthenticatedMessage, validatePassword } from '../../../lib/auth-utils';
import type { Config } from '@verdaccio/types';
import type { $Response, Router } from 'express';
@ -35,6 +35,11 @@ export default function(route: Router, auth: IAuth, config: Config) {
token,
});
} else {
if (validatePassword(password) === false) {
// eslint-disable-next-line new-cap
return next(ErrorCode.getCode(HTTP_STATUS.BAD_REQUEST, API_ERROR.PASSWORD_SHORT()));
}
auth.add_user(name, password, async function(err, user) {
if (err) {
if (err.status >= HTTP_STATUS.BAD_REQUEST && err.status < HTTP_STATUS.INTERNAL_ERROR) {

@ -0,0 +1,73 @@
/**
* @prettier
*/
// @flow
import _ from 'lodash';
import { API_ERROR, APP_ERROR, HTTP_STATUS, SUPPORT_ERRORS } from '../../../../lib/constants';
import { ErrorCode } from '../../../../lib/utils';
import { validatePassword } from '../../../../lib/auth-utils';
import type { $Response, Router } from 'express';
import type { $NextFunctionVer, $RequestExtend, IAuth } from '../../../../../types';
export default function(route: Router, auth: IAuth) {
const buildProfile = name => ({
tfa: false,
name,
email: '',
email_verified: false,
created: '',
updated: '',
cidr_whitelist: null,
fullname: '',
});
route.get('/-/npm/v1/user', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) {
if (_.isNil(req.remote_user.name) === false) {
return next(buildProfile(req.remote_user.name));
}
res.status(HTTP_STATUS.UNAUTHORIZED);
return next({
message: API_ERROR.MUST_BE_LOGGED,
});
});
route.post('/-/npm/v1/user', function(req: $RequestExtend, res: $Response, next: $NextFunctionVer) {
if (_.isNil(req.remote_user.name)) {
res.status(HTTP_STATUS.UNAUTHORIZED);
return next({
message: API_ERROR.MUST_BE_LOGGED,
});
}
const { password, tfa } = req.body;
const { name } = req.remote_user;
if (_.isNil(password) === false) {
if (validatePassword(password.new) === false) {
/* eslint new-cap:off */
return next(ErrorCode.getCode(HTTP_STATUS.UNAUTHORIZED, API_ERROR.PASSWORD_SHORT()));
/* eslint new-cap:off */
}
auth.changePassword(name, password.old, password.new, (err, isUpdated) => {
if (_.isNull(err) === false) {
return next(ErrorCode.getCode(err.status, err.message) || ErrorCode.getConflict(err.message));
}
if (isUpdated) {
return next(buildProfile(req.remote_user.name));
} else {
return next(ErrorCode.getInternalError(API_ERROR.INTERNAL_SERVER_ERROR));
}
});
} else if (_.isNil(tfa) === false) {
return next(ErrorCode.getCode(HTTP_STATUS.SERVICE_UNAVAILABLE, SUPPORT_ERRORS.TFA_DISABLED));
} else {
return next(ErrorCode.getCode(HTTP_STATUS.INTERNAL_ERROR, APP_ERROR.PROFILE_ERROR));
}
});
}

@ -15,6 +15,7 @@ import distTags from './api/dist-tags';
import publish from './api/publish';
import search from './api/search';
import pkg from './api/package';
import profile from './api/v1/profile';
const { match, validateName, validatePackage, encodeScopePackage, antiLoop } = require('../middleware');
@ -48,6 +49,7 @@ export default function(config: Config, auth: IAuth, storage: IStorageHandler) {
// for "npm whoami"
whoami(app);
pkg(app, auth, storage, config);
profile(app, auth);
search(app, auth, storage);
user(app, auth, config);
distTags(app, auth, storage);

@ -5,12 +5,16 @@
import _ from 'lodash';
import { convertPayloadToBase64, ErrorCode } from './utils';
import { API_ERROR, HTTP_STATUS, ROLES, TIME_EXPIRATION_7D, TOKEN_BASIC, TOKEN_BEARER, CHARACTER_ENCODING } from './constants';
import { API_ERROR, HTTP_STATUS, ROLES, TIME_EXPIRATION_7D, TOKEN_BASIC, TOKEN_BEARER, CHARACTER_ENCODING, DEFAULT_MIN_LIMIT_PASSWORD } 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';
export function validatePassword(password: string, minLength: number = DEFAULT_MIN_LIMIT_PASSWORD) {
return typeof password === 'string' && password.length >= minLength;
}
/**
* Create a RemoteUser object
* @return {Object} { name: xx, pluginGroups: [], real_groups: [] }

@ -5,7 +5,7 @@
import _ from 'lodash';
import { API_ERROR, TOKEN_BASIC, TOKEN_BEARER } from './constants';
import { API_ERROR, SUPPORT_ERRORS, TOKEN_BASIC, TOKEN_BEARER } from './constants';
import loadPlugin from '../lib/plugin-loader';
import { aesEncrypt, signPayload } from './crypto-utils';
import {
@ -60,6 +60,31 @@ class Auth implements IAuth {
this.plugins.push(getDefaultPlugins());
}
changePassword(username: string, password: string, newPassword: string, cb: Callback) {
const validPlugins = _.filter(this.plugins, plugin => _.isFunction(plugin.changePassword));
if (_.isEmpty(validPlugins)) {
return cb(ErrorCode.getInternalError(SUPPORT_ERRORS.PLUGIN_MISSING_INTERFACE));
}
for (const plugin of validPlugins) {
this.logger.trace({ username }, 'updating password for @{username}');
plugin.changePassword(username, password, newPassword, (err, profile) => {
if (err) {
this.logger.error(
{ username, err },
`An error has been produced
updating the password for @{username}. Error: @{err.message}`
);
return cb(err);
}
this.logger.trace({ username }, 'updated password for @{username} was successful');
return cb(null, profile);
});
}
}
authenticate(username: string, password: string, cb: Callback) {
const plugins = this.plugins.slice(0);
const self = this;

@ -10,6 +10,7 @@ export const DEFAULT_DOMAIN: string = 'localhost';
export const TIME_EXPIRATION_24H: string = '24h';
export const TIME_EXPIRATION_7D: string = '7d';
export const DIST_TAGS = 'dist-tags';
export const DEFAULT_MIN_LIMIT_PASSWORD: number = 3;
export const keyPem = 'verdaccio-key.pem';
export const certPem = 'verdaccio-cert.pem';
@ -87,7 +88,15 @@ export const API_MESSAGE = {
LOGGED_OUT: 'Logged out',
};
export const SUPPORT_ERRORS = {
PLUGIN_MISSING_INTERFACE: 'the plugin does not provide implementation of the requested feature',
TFA_DISABLED: 'the two-factor authentication is not yet supported',
};
export const API_ERROR = {
PASSWORD_SHORT: (passLength: number = DEFAULT_MIN_LIMIT_PASSWORD) =>
`The provided password is too short. Please pick a password longer than ${passLength} characters.`,
MUST_BE_LOGGED: 'You must be logged in to publish packages.',
PLUGIN_ERROR: 'bug in the auth plugin system',
CONFIG_BAD_FORMAT: 'config file must be an object',
BAD_USERNAME_PASSWORD: 'bad username/password, access denied',
@ -116,6 +125,7 @@ export const API_ERROR = {
export const APP_ERROR = {
CONFIG_NOT_VALID: 'CONFIG: it does not look like a valid config file',
PROFILE_ERROR: 'profile unexpected error',
};
export const DEFAULT_NO_README = 'ERROR: No README data found!';

@ -1,3 +1,7 @@
/**
* @prettier
*/
// @flow
// this file is not aim to be tested, just to check flow definitions
@ -5,92 +9,93 @@
import Config from '../../../../src/lib/config';
import LoggerApi from '../../../../src/lib/logger';
import type {
Config as AppConfig,
PackageAccess,
IPluginAuth,
RemoteUser,
Logger,
PluginOptions
} from '@verdaccio/types';
import type { Config as AppConfig, PackageAccess, IPluginAuth, RemoteUser, Logger, PluginOptions } from '@verdaccio/types';
class ExampleAuthPlugin implements IPluginAuth {
config: AppConfig;
logger: Logger;
config: AppConfig;
logger: Logger;
constructor(config: AppConfig, options: PluginOptions) {
this.config = config;
this.logger = options.logger;
}
constructor(config: AppConfig, options: PluginOptions) {
this.config = config;
this.logger = options.logger;
}
adduser(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
adduser(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
authenticate(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
changePassword(username, password, newPassword, cb: verdaccio$Callback): void {
cb();
}
authenticate(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
allow_access(user: RemoteUser, pkg: PackageAccess, cb: verdaccio$Callback): void {
cb();
}
cb();
}
allow_publish(user: RemoteUser, pkg: PackageAccess, cb: verdaccio$Callback): void {
cb();
}
cb();
}
}
type SubTypePackageAccess = PackageAccess & {
sub?: boolean
}
sub?: boolean,
};
class ExampleAuthCustomPlugin implements IPluginAuth {
config: AppConfig;
logger: Logger;
config: AppConfig;
logger: Logger;
constructor(config: AppConfig, options: PluginOptions) {
this.config = config;
this.logger = options.logger;
}
constructor(config: AppConfig, options: PluginOptions) {
this.config = config;
this.logger = options.logger;
}
adduser(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
adduser(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
authenticate(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
changePassword(username, password, newPassword, cb: verdaccio$Callback): void {
cb();
}
authenticate(user: string, password: string, cb: verdaccio$Callback): void {
cb();
}
allow_access(user: RemoteUser, pkg: SubTypePackageAccess, cb: verdaccio$Callback): void {
cb();
}
cb();
}
allow_publish(user: RemoteUser, pkg: SubTypePackageAccess, cb: verdaccio$Callback): void {
cb();
}
cb();
}
}
const config1: AppConfig = new Config({
storage: './storage',
self_path: '/home/sotrage'
storage: './storage',
self_path: '/home/sotrage',
});
const options: PluginOptions = {
config: config1,
logger: LoggerApi.logger.child()
}
config: config1,
logger: LoggerApi.logger.child(),
};
const auth = new ExampleAuthPlugin(config1, options);
const authSub = new ExampleAuthCustomPlugin(config1, options);
const remoteUser: RemoteUser = {
groups: [],
real_groups: [],
name: 'test'
groups: [],
real_groups: [],
name: 'test',
};
auth.authenticate('user', 'pass', () => {});
auth.allow_access(remoteUser, {}, () => {});
auth.allow_publish(remoteUser, {}, () => {});
authSub.authenticate('user', 'pass', () => {});
authSub.allow_access(remoteUser, {sub: true}, () => {});
authSub.allow_publish(remoteUser, {sub: true}, () => {});
authSub.allow_access(remoteUser, { sub: true }, () => {});
authSub.allow_publish(remoteUser, { sub: true }, () => {});

@ -22,7 +22,7 @@ export function getPackage(
export function addUser(request: any, user: string, credentials: any,
statusCode: number = HTTP_STATUS.CREATED) {
// $FlowFixMe
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
request.put(`/-/user/org.couchdb.user:${user}`)
.send(credentials)
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
@ -32,3 +32,43 @@ export function addUser(request: any, user: string, credentials: any,
});
});
}
export async function getNewToken(request: any, credentials: any) {
return new Promise(async (resolve) => {
const [err, res] = await
addUser(request, credentials.name, credentials);
expect(err).toBeNull();
const {token, ok} = res.body;
expect(ok).toBeDefined();
expect(token).toBeDefined();
expect(typeof token).toBe('string');
resolve(token);
});
}
export function getProfile(request: any, token: string, statusCode: number = HTTP_STATUS.OK) {
// $FlowFixMe
return new Promise((resolve) => {
request.get(`/-/npm/v1/user`)
.set('authorization', `Bearer ${token}`)
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(statusCode)
.end(function(err, res) {
return resolve([err, res]);
});
});
}
export function postProfile(request: any, body: any, token: string, statusCode: number = HTTP_STATUS.OK) {
// $FlowFixMe
return new Promise((resolve) => {
request.post(`/-/npm/v1/user`)
.send(body)
.set('authorization', `Bearer ${token}`)
.expect(HEADER_TYPE.CONTENT_TYPE, HEADERS.JSON_CHARSET)
.expect(statusCode)
.end(function(err, res) {
return resolve([err, res]);
});
});
}

@ -0,0 +1,130 @@
// @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 {mockServer} from './mock';
import {parseConfigFile} from '../../../src/lib/utils';
import {parseConfigurationFile} from '../__helper';
import {getNewToken, getProfile, postProfile} from './__api-helper';
import {setup} from '../../../src/lib/logger';
import {API_ERROR, HTTP_STATUS, SUPPORT_ERRORS} from '../../../src/lib/constants';
setup([]);
const parseConfigurationProfile = () => {
return parseConfigurationFile(`profile/profile`);
};
describe('endpoint user profile', () => {
let config;
let app;
let mockRegistry;
beforeAll(function(done) {
const store = path.join(__dirname, '../partials/store/test-profile-storage');
const mockServerPort = 55544;
rimraf(store, async () => {
const parsedConfig = parseConfigFile(parseConfigurationProfile());
const configForTest = _.clone(parsedConfig);
configForTest.storage = store;
configForTest.auth = {
htpasswd: {
file: './test-profile-storage/.htpasswd'
}
};
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 fetch a profile of logged user', async (done) => {
const credentials = { name: 'JotaJWT', password: 'secretPass' };
const token = await getNewToken(request(app), credentials);
const [err1, res1] = await getProfile(request(app), token);
expect(err1).toBeNull();
expect(res1.body.name).toBe(credentials.name);
done();
});
describe('change password', () => {
test('should change password successfully', async (done) => {
const credentials = { name: 'userTest2000', password: 'secretPass000' };
const body = {
password: {
new: '12345678',
old: credentials.password,
}
};
const token = await getNewToken(request(app), credentials);
const [err1, res1] = await postProfile(request(app), body, token);
expect(err1).toBeNull();
expect(res1.body.name).toBe(credentials.name);
done();
});
test('should change password is too short', async (done) => {
const credentials = { name: 'userTest2001', password: 'secretPass001' };
const body = {
password: {
new: 'p1',
old: credentials.password,
}
};
const token = await getNewToken(request(app), credentials);
const [, resp] = await postProfile(request(app), body, token, HTTP_STATUS.UNAUTHORIZED);
expect(resp.error).not.toBeNull();
expect(resp.error.text).toMatch(API_ERROR.PASSWORD_SHORT());
done();
});
});
describe('change tfa', () => {
test('should report TFA is disabled', async (done) => {
const credentials = { name: 'userTest2002', password: 'secretPass002' };
const body = {
tfa: {}
};
const token = await getNewToken(request(app), credentials);
const [, resp] = await postProfile(request(app), body, token, HTTP_STATUS.SERVICE_UNAVAILABLE);
expect(resp.error).not.toBeNull();
expect(resp.error.text).toMatch(SUPPORT_ERRORS.TFA_DISABLED);
done();
});
});
describe('error handling', () => {
test('should forbid to fetch a profile with invalid token', async (done) => {
const [, resp] = await getProfile(request(app), `fakeToken`, HTTP_STATUS.UNAUTHORIZED);
expect(resp.error).not.toBeNull();
expect(resp.error.text).toMatch(API_ERROR.MUST_BE_LOGGED);
done();
});
test('should forbid to update a profile with invalid token', async (done) => {
const [, resp] = await postProfile(request(app), {}, `fakeToken`, HTTP_STATUS.UNAUTHORIZED);
expect(resp.error).not.toBeNull();
expect(resp.error.text).toMatch(API_ERROR.MUST_BE_LOGGED);
done();
});
});
});

@ -186,7 +186,7 @@ describe('endpoint unit test', () => {
}
expect(res.body.error).toBeDefined();
expect(res.body.error).toMatch(/username and password is required/);
expect(res.body.error).toMatch('username and password is required');
done();
});
});
@ -208,7 +208,7 @@ describe('endpoint unit test', () => {
expect(res.body.error).toBeDefined();
//FIXME: message is not 100% accurate
expect(res.body.error).toMatch(/username and password is required/);
expect(res.body.error).toMatch(API_ERROR.PASSWORD_SHORT());
done();
});
});

@ -0,0 +1,27 @@
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: $authenticated
publish: $authenticated
'**':
access: $authenticated
publish: $authenticated
logs:
- { type: stdout, format: pretty, level: http }

@ -67,6 +67,17 @@ export type Utils = {
semverSort: (keys: Array<string>) => Array<string>;
}
export type Profile = {
tfa: boolean;
name: string;
email: string;
email_verified: string;
created: string;
updated: string;
cidr_whitelist: any;
fullname: string;
}
export type $RequestExtend = $Request & {remote_user?: any}
export type $ResponseExtend = $Response & {cookies?: any}
export type $NextFunctionVer = NextFunction & mixed;

BIN
yarn.lock

Binary file not shown.