@anddev-oss/verdaccio-htpasswd-azure
Version:
htpasswd auth plugin for Verdaccio that saves in Azure Blob
417 lines • 18.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_SLOW_VERIFY_MS = void 0;
const debug_1 = __importDefault(require("debug"));
const core_1 = require("@verdaccio/core");
const azureAuth_1 = require("./azureAuth");
const utils_1 = require("./utils");
const constants_1 = require("./constants");
const debug = (0, debug_1.default)('verdaccio:plugin:htpasswdAzure');
exports.DEFAULT_SLOW_VERIFY_MS = 200;
/**
* Custom Verdaccio Authenticate Plugin.
*/
class AuthCustomPlugin {
logger;
users;
maxUsers;
hashConfig;
slowVerifyMs;
lastTime;
blobServiceClient;
containerClient;
containerName;
blobName;
constructor(config, options) {
this.users = {};
// verdaccio logger
this.logger = options.logger;
// all this "verdaccio_config" stuff is for b/w compatibility only
this.maxUsers = config.max_users ? config.max_users : Infinity;
let algorithm;
let rounds;
if (typeof config.algorithm === 'undefined') {
algorithm = core_1.constants.HtpasswdHashAlgorithm.bcrypt;
}
else if (core_1.constants.HtpasswdHashAlgorithm[config.algorithm] !== undefined) {
algorithm = core_1.constants.HtpasswdHashAlgorithm[config.algorithm];
}
else {
this.logger.warn(`The algorithm selected %s is invalid, switching to to default one "bcrypt", password validation can be affected`, config.algorithm);
algorithm = core_1.constants.HtpasswdHashAlgorithm.bcrypt;
}
debug(`password hash algorithm: ${algorithm}`);
if (algorithm === core_1.constants.HtpasswdHashAlgorithm.bcrypt) {
rounds = config.rounds || utils_1.DEFAULT_BCRYPT_ROUNDS;
}
else if (config.rounds !== undefined) {
this.logger.warn({ algo: algorithm }, 'Option "rounds" is not valid for "@{algo}" algorithm');
}
this.hashConfig = {
algorithm,
rounds,
};
this.lastTime = null;
const authMethod = process.env.HTPASSWD_AZ_AUTH_METHOD || config.authMethod || 'ConnectionString';
const containerName = process.env.HTPASSWD_AZ_CONTAINER_NAME || config.containerName;
const accountName = process.env.HTPASSWD_AZ_ACCOUNT_NAME || config.accountName;
const accountKey = process.env.HTPASSWD_AZ_ACCOUNT_KEY || config.accountKey;
const accountDomain = process.env.HTPASSWD_AZ_ACCOUNT_DOMAIN || config.accountDomain || 'blob.core.windows.net';
switch (authMethod) {
case 'ConnectionString': {
const { blobClient, containerClient } = (0, azureAuth_1.setupConnectionStringAuth)(this.logger, process.env.AZ_STORAGE_CONNECTION_STRING || config.connectionString, containerName);
this.blobServiceClient = blobClient;
this.containerClient = containerClient;
break;
}
case 'DefaultAzureCredential': {
if (!accountName) {
throw new Error('Account name is required!');
}
const { blobClient, containerClient } = (0, azureAuth_1.setupDefaultAzureCredentialAuth)(this.logger, accountName, containerName, accountDomain);
this.blobServiceClient = blobClient;
this.containerClient = containerClient;
break;
}
case 'StorageSharedKeyCredential':
if (!accountName) {
throw new Error('Account name is required!');
}
if (!accountKey) {
throw new Error('Account key is required!');
}
const { blobClient, containerClient } = (0, azureAuth_1.setupStorageSharedKeyCredential)(this.logger, accountName, accountKey, containerName, accountDomain);
this.blobServiceClient = blobClient;
this.containerClient = containerClient;
break;
default:
this.logger.error(`${constants_1.LOGGER_PREFIX}: Unsupported authentication method: ${authMethod}`);
throw new Error(`Unsupported authentication method: ${authMethod}`);
}
this.containerName = config.containerName;
this.blobName = config.blobName || '.htpasswd';
if (config.slow_verify_ms) {
this.logger.info({ ms: config.slow_verify_ms }, 'slow_verify_ms enabled for @{ms}');
}
this.slowVerifyMs = config.slow_verify_ms || exports.DEFAULT_SLOW_VERIFY_MS;
}
/**
* Authenticate an user.
* @param user user to log
* @param password provided password
* @param cb callback function
*/
authenticate(user, password, cb) {
debug('authenticate %s', user);
this.reload(async (err) => {
debug('reloaded');
if (err) {
if (err.statusCode === 404) {
debug('.htpasswd blob not found; initializing with an empty file.');
this.users = {}; // Reset users to an empty object
return cb(null, false); // User cannot be authenticated
}
debug('error %o', err);
return cb(err, false);
}
if (!this.users[user]) {
debug('user %s not found', user);
return cb(null, false);
}
let passwordValid = false;
try {
const start = new Date();
passwordValid = await (0, utils_1.verifyPassword)(password, this.users[user]);
const durationMs = new Date().getTime() - start.getTime();
if (durationMs > this.slowVerifyMs) {
debug('password for user "%s" took %sms to verify', user, durationMs);
this.logger.warn({ user, durationMs }, 'Password for user "@{user}" took @{durationMs}ms to verify');
}
}
catch (error) {
this.logger.error({ message: error.message }, 'Unable to verify user password: @{message}');
}
if (!passwordValid) {
debug('password invalid for %s', user);
return cb(null, false);
}
// Authentication succeeded!
// Return all user groups this user has access to.
// (This particular plugin has no concept of user groups, so just return the user.)
return cb(null, [user]);
});
}
/**
* Add user
* 1. lock file for writing (other processes can still read)
* 2. reload .htpasswd
* 3. write new data into .htpasswd.tmp
* 4. move .htpasswd.tmp to .htpasswd
* 5. reload .htpasswd
* 6. unlock file
*
* @param {string} user
* @param {string} password
* @param {function} realCb
* @returns {Promise<any>}
*/
async adduser(user, password, cb) {
debug('adduser %s', user);
const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobName);
try {
// Ensure the blob exists before proceeding
let blobExists = true;
try {
await blockBlobClient.getProperties();
}
catch (err) {
if (err.statusCode === 404) {
debug('Blob not found; creating a new one with empty content.');
blobExists = false;
const emptyContent = '';
await blockBlobClient.upload(emptyContent, Buffer.byteLength(emptyContent));
debug('Blob created with empty content.');
}
else {
debug('Error accessing blob: %o', err);
return cb(err, false); // Return the Azure Blob error directly
}
}
// Perform preliminary sanity checks
let sanity = await (0, utils_1.sanityCheck)(user, password, utils_1.verifyPassword, this.users, this.maxUsers);
if (sanity) {
debug('sanity check failed');
// Create a valid AuthError object
const authError = {
code: 400, // Application-specific error code
message: typeof sanity === 'string' ? sanity : 'Sanity check failed',
status: 400, // Numeric HTTP-like status code
statusCode: 400,
expose: true,
name: 'AuthError',
};
return cb(authError, false);
}
// Acquire a lease on the blob for mutual exclusion
const leaseClient = blockBlobClient.getBlobLeaseClient();
const leaseResponse = await leaseClient.acquireLease(15);
const leaseId = leaseResponse.leaseId;
let locked = true;
try {
debug('Blob locked with lease ID: %s', leaseId);
// Download the blob content
let body = '';
if (blobExists) {
const downloadResponse = await blockBlobClient.downloadToBuffer();
body = downloadResponse.toString('utf8');
}
// Parse users and perform final sanity check
this.users = (0, utils_1.parseHTPasswd)(body);
sanity = await (0, utils_1.sanityCheck)(user, password, utils_1.verifyPassword, this.users, this.maxUsers);
if (sanity) {
debug('sanity check failed');
// Create a valid AuthError object
const authError = {
code: 400, // Application-specific error code
message: typeof sanity === 'string' ? sanity : 'Sanity check failed',
status: 400, // Numeric HTTP-like status code
statusCode: 400,
expose: true,
name: 'AuthError',
};
return cb(authError, false);
}
// Add the new user
const updatedBody = await (0, utils_1.addUserToHTPasswd)(body, user, password, this.hashConfig);
// Upload updated content to the blob
await blockBlobClient.upload(updatedBody, Buffer.byteLength(updatedBody), {
conditions: { leaseId },
});
debug('Updated htpasswd content uploaded to Azure Blob Storage');
// Update in-memory users
this.users = (0, utils_1.parseHTPasswd)(updatedBody);
cb(null, []); // Success: pass an empty array for the second parameter
}
catch (err) {
debug('Error during user addition or blob upload: %o', err);
cb(err, false); // Return the Azure Blob error directly
}
finally {
if (locked) {
try {
await leaseClient.releaseLease();
debug('Blob lease released');
}
catch (releaseErr) {
debug('Error releasing blob lease: %o', releaseErr);
}
}
}
}
catch (err) {
debug('Unexpected error in adduser: %o', err);
cb(err, false); // Return a general error for unexpected failures
}
}
/**
* changePassword - change password for existing user.
* @param {string} user
* @param {string} password
* @param {string} newPassword
* @param {function} realCb
* @returns {function}
*/
async changePassword(user, password, newPassword, cb) {
debug('change password %s', user);
const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobName);
try {
// Acquire a lease on the blob to ensure mutual exclusion
const leaseClient = blockBlobClient.getBlobLeaseClient();
const leaseResponse = await leaseClient.acquireLease(15); // Lease duration: 15 seconds
const leaseId = leaseResponse.leaseId; // Extract leaseId
let locked = true;
try {
debug('Blob locked with lease ID: %s', leaseId);
// Download the current blob content
let body = '';
try {
const downloadResponse = await blockBlobClient.downloadToBuffer();
body = downloadResponse.toString('utf8');
}
catch (err) {
if (err.statusCode !== 404) {
throw err; // Propagate error unless it's a "blob not found" error
}
debug('Blob not found; initializing with an empty file.');
}
// Parse the current users
this.users = (0, utils_1.parseHTPasswd)(body);
// Update the user's password
const updatedBody = await (0, utils_1.changePasswordToHTPasswd)(body, user, password, newPassword, this.hashConfig);
debug('Password updated for user %s', user);
// Upload the updated content back to Azure Blob Storage
await blockBlobClient.upload(updatedBody, Buffer.byteLength(updatedBody), {
conditions: { leaseId }, // Use extracted leaseId
});
debug('Updated htpasswd content uploaded to Azure Blob Storage');
// Update in-memory users
this.users = (0, utils_1.parseHTPasswd)(updatedBody);
cb(null, false);
}
catch (err) {
debug('Error changing password: %o', err);
cb(err, false);
}
finally {
// Release the lease
if (locked) {
try {
await leaseClient.releaseLease();
debug('Blob lease released');
}
catch (releaseErr) {
debug('Error releasing blob lease: %o', releaseErr);
}
}
}
}
catch (err) {
debug('Error in changePassword: %o', err);
cb(err, false);
}
}
// /**
// * Triggered on each access request
// * @param user
// * @param pkg
// * @param cb
// */
// public allow_access(user: RemoteUser, pkg: PackageAccess, cb: AuthAccessCallback): void {
// /**
// * This code is just an example for demostration purpose
// if (user.name === this.foo && pkg?.access?.includes[user.name]) {
// this.logger.debug({name: user.name}, 'your package has been granted for @{name}');
// cb(null, true)
// } else {
// this.logger.error({name: user.name}, '@{name} is not allowed to access this package');
// cb(getInternalError("error, try again"), false);
// }
// */
// }
// /**
// * Triggered on each publish request
// * @param user
// * @param pkg
// * @param cb
// */
// public allow_publish(user: RemoteUser, pkg: PackageAccess, cb: AuthAccessCallback): void {
// /**
// * This code is just an example for demostration purpose
// if (user.name === this.foo && pkg?.access?.includes[user.name]) {
// this.logger.debug({name: user.name}, '@{name} has been granted to publish');
// cb(null, true)
// } else {
// this.logger.error({name: user.name}, '@{name} is not allowed to publish this package');
// cb(getInternalError("error, try again"), false);
// }
// */
// }
// public allow_unpublish(user: RemoteUser, pkg: PackageAccess, cb: AuthAccessCallback): void {
// /**
// * This code is just an example for demostration purpose
// if (user.name === this.foo && pkg?.access?.includes[user.name]) {
// this.logger.debug({name: user.name}, '@{name} has been granted to unpublish');
// cb(null, true)
// } else {
// this.logger.error({name: user.name}, '@{name} is not allowed to publish this package');
// cb(getInternalError("error, try again"), false);
// }
// */
// }
/**
* Reload users
* @param {function} callback
*/
async reload(cb) {
const blockBlobClient = this.containerClient.getBlockBlobClient(this.blobName);
const logger = this.logger;
logger.info(`Reloading users from Azure Blob Storage: ${this.blobName}`);
try {
const properties = await blockBlobClient.getProperties();
const lastModified = properties.lastModified;
// Check if the blob has been modified
if (this.lastTime && lastModified && this.lastTime.getTime() === lastModified.getTime()) {
logger.info('No changes detected in .htpasswd; skipping reload.');
return cb(null, false);
}
this.lastTime = lastModified || new Date();
try {
const buffer = await blockBlobClient.downloadToBuffer();
// Log raw buffer as a hexadecimal string for debugging
logger.debug('Blob content (raw buffer as hex):', buffer.toString('hex'));
if (!buffer || buffer.length === 0) {
throw new Error('Blob content is unexpectedly empty.');
}
// Convert buffer to UTF-8 string for parsing
const content = buffer.toString('utf8');
logger.debug('Blob content (UTF-8 string):', content);
this.users = (0, utils_1.parseHTPasswd)(content);
logger.info(`Reloaded users: Total ${Object.keys(this.users).length}`);
cb(null, false);
}
catch (err) {
logger.error(`Failed to download .htpasswd: ${err.message}`);
cb(err, false);
}
}
catch (err) {
logger.error(`Error checking .htpasswd blob properties: ${err.message}`);
cb(err, false);
}
}
}
exports.default = AuthCustomPlugin;
//# sourceMappingURL=index.js.map