UNPKG

@anddev-oss/verdaccio-htpasswd-azure

Version:

htpasswd auth plugin for Verdaccio that saves in Azure Blob

417 lines 18.8 kB
"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