From 1658f071b321d9cf37c6b7f215b8d508d716f0ce Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 6 Aug 2025 19:40:50 +1000 Subject: [PATCH 1/3] Added support for all login methods: Normal, TOTP, Magic Link, MFA. --- .gitignore | 5 +- bin/commands/login.js | 553 +++++++++++++++++++++++++++++++++++------- package-lock.json | 6 +- package.json | 2 +- 4 files changed, 469 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index bb6fa4d..f43b8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,7 @@ dist .dynamodb/ # TernJS port file -.tern-port \ No newline at end of file +.tern-port + +# Visual Studio +/.vs diff --git a/bin/commands/login.js b/bin/commands/login.js index af692b2..96d9299 100644 --- a/bin/commands/login.js +++ b/bin/commands/login.js @@ -3,6 +3,13 @@ import { interactiveEnv, getAgent } from './env.js' import { updateConfig, getConfig } from './config.js'; import yargsInteractive from 'yargs-interactive'; +const AuthMethods = { + Normal: 'Normal', + TOTP: 'TOTP (Time-based One-time Password)', + Link: 'Passwordless Authentication with Magic Links', + MFA: 'Multi Factor Authentication (MFA)' +}; + export function loginCommand() { return { command: 'login [user]', @@ -17,134 +24,496 @@ export function loginCommand() { } } +/** + * Retrieves the current user's configuration, ensuring they are authenticated. + * If no user is configured, it triggers the interactive login process. + * + * @param {object} argv - The command-line arguments. + * @param {object} env - The configuration object for the current environment. + * @returns {Promise} The user object containing the apiKey. + */ export async function setupUser(argv, env) { - let user = {}; - let askLogin = true; - if (argv.apiKey) { - user.apiKey = argv.apiKey; - askLogin = false; + return { apiKey: argv.apiKey }; } - if (!user.apiKey && env.users) { - user = env.users[argv.user] || env.users[env.current.user]; - askLogin = false; - } + if (env.users) { + const activeUser = env.users[argv.user] || env.users[env.current?.user]; - if (askLogin && argv.host) { + if (activeUser?.apiKey) { + return activeUser; + } + } + + if (argv.host) { console.log('Please add an --apiKey to the command as overriding the host requires that.') process.exit(); } - else if (askLogin) { - console.log('Current user not set, please login') - await interactiveLogin(argv, { - environment: { - type: 'input', - default: getConfig()?.current?.env, - prompt: 'never' - }, - username: { - type: 'input' - }, - password: { - type: 'password' - }, - interactive: { - default: true - } - }) - user = env.users[env.current.user]; - } - - return user; + + console.log('Current user not set, please login.'); + await interactiveLogin(argv, { + method: { + type: 'list', + describe: 'Select authentication method', + choices: Object.values(AuthMethods) + }, + environment: { + type: 'input', + default: getConfig()?.current?.env, + prompt: 'never' + }, + username: { + type: 'input' + }, + password: { + type: 'password', + when: (answers) => answers.method === AuthMethods.Normal || answers.method === AuthMethods.MFA + }, + interactive: { + default: true + } + }); + + const updatedConfig = getConfig(); + const updatedEnv = updatedConfig.env[updatedConfig.current.env]; + const finalUser = updatedEnv.users[updatedEnv.current.user]; + + if (!finalUser?.apiKey) { + console.error("Login seemed successful, but failed to retrieve user data. Please try again."); + process.exit(); + } + + return finalUser; } async function handleLogin(argv) { argv.user ? changeUser(argv) : interactiveLogin(argv, { + method: { + type: 'list', + describe: 'Select authentication method', + choices: Object.values(AuthMethods) + }, environment: { type: 'input', default: getConfig()?.current?.env || 'dev', prompt: 'if-no-arg' - }, - username: { + }, + username: { type: 'input' - }, - password: { - type: 'password' - }, - interactive: { - default: true - } + }, + password: { + type: 'password', + when: (answers) => answers.method === AuthMethods.Normal || answers.method === AuthMethods.MFA + }, + interactive: { + default: true + } }) } +/** + * Manages the interactive login session. + * It first prompts the user for login details (method, username, etc.). + * Then, it ensures the selected environment is fully configured before proceeding to the actual login logic. + * @param {object} argv - The command-line arguments. + * @param {object} options - The configuration for the yargs-interactive prompt. + * @returns {Promise} + */ export async function interactiveLogin(argv, options) { - if (argv.verbose) console.info('Now logging in') - await yargsInteractive() - .interactive(options) - .then(async (result) => { - if (!getConfig().env || !getConfig().env[result.environment] || !getConfig().env[result.environment].host || !getConfig().env[result.environment].protocol) { - if (!argv.host) - console.log(`The environment specified is missing parameters, please specify them`) - await interactiveEnv(argv, { - environment: { - type: 'input', - default: result.environment, - prompt: 'never' - }, - host: { - describe: 'Enter your host including protocol, i.e "https://yourHost.com":', - type: 'input', - prompt: 'if-no-arg' - }, - interactive: { - default: true - } - }) + if (argv.verbose) { + console.info('Now logging in'); + } + + const result = await yargsInteractive().interactive(options); + + const config = getConfig(); + const envConfig = config.env?.[result.environment]; + const isEnvIncomplete = !envConfig || !envConfig.host || !envConfig.protocol; + + if (isEnvIncomplete) { + if (!argv.host) { + console.log(`The environment specified is missing parameters, please specify them`) + } + + const envSetupOptions = { + environment: { + type: 'input', + default: result.environment, + prompt: 'never' + }, + host: { + describe: 'Enter your host including protocol, i.e "https://yourHost.com":', + type: 'input', + prompt: 'if-no-arg' + }, + interactive: { + default: true } - await loginInteractive(result, argv.verbose); - }); + }; + + await interactiveEnv(argv, envSetupOptions); + } + + await loginInteractive(result, argv.verbose); } +/** + * Handles the interactive login flow after collecting user input. + * It authenticates the user, retrieves an API key, and updates the local configuration. + * @param {object} result - The user input from yargs-interactive, including environment, username, etc. + * @param {boolean} verbose - Flag for verbose logging. + * @returns {Promise} + */ async function loginInteractive(result, verbose) { - var protocol = getConfig().env[result.environment].protocol; - var token = await login(result.username, result.password, result.environment, protocol, verbose); - if (!token) return; - var apiKey = await getApiKey(token, result.environment, protocol, verbose) - if (!apiKey) return; - getConfig().env = getConfig().env || {}; - getConfig().env[result.environment].users = getConfig().env[result.environment].users || {}; - getConfig().env[result.environment].users[result.username] = getConfig().env[result.environment].users[result.username] || {}; - getConfig().env[result.environment].users[result.username].apiKey = apiKey; - getConfig().env[result.environment].current = getConfig().env[result.environment].current || {}; - getConfig().env[result.environment].current.user = result.username; - console.log("You're now logged in as " + result.username) + const { environment, username } = result; + + const config = getConfig(); + const envConfig = config.env[environment]; + const protocol = envConfig.protocol; + + const token = await login(result, protocol, verbose); + if (!token) { + console.error("Login failed: Could not retrieve an authentication token."); + return; + } + + const apiKey = await getApiKey(token, environment, protocol, verbose); + if (!apiKey) { + console.error("Failed to retrieve or generate an API key after login."); + return; + } + + envConfig.users = envConfig.users || {}; + envConfig.users[username] = { apiKey: apiKey }; + envConfig.current = { user: username }; + updateConfig(); + + console.log(`You're now logged in as '${username}' for the '${environment}' environment.`); +} + +/** + * Calls the required authentication method. + * @param {object} options - Object with data from user (method, username, password, environment). + * @param {string} protocol - Protocol (http/https). + * @param {boolean} verbose - True if logging is needed. + * @returns {Promise} Authentication token. + */ +async function login(options, protocol, verbose) { + const { method, username, password, environment: env } = options; + + switch (method) { + case AuthMethods.Normal: + return await loginWithPassword({ username, password, env, protocol, verbose }); + + case AuthMethods.MFA: + return await loginWithMFA({ username, password, env, protocol, verbose }); + + case AuthMethods.TOTP: + return await loginWithCode({ username, env, protocol, verbose }); + + case AuthMethods.Link: + return await loginWithLink({ username, env, protocol, verbose }); + + default: + console.error(`Unknown authentication method: ${method}`); + return; + } } -async function login(username, password, env, protocol, verbose) { - let data = new URLSearchParams(); - data.append('Username', username); - data.append('Password', password); - var res = await fetch(`${protocol}://${getConfig().env[env].host}/Admin/Authentication/Login`, { +/** + * Performs the common, sequential steps for authentication by username and password. + * This involves two requests: first to submit the username to initiate a session, + * and second to submit the password using that session's cookie. + * + * @param {object} credentials - The user's credentials and environment details. + * @param {string} credentials.username - The user's username. + * @param {string} credentials.password - The user's password. + * @param {string} credentials.env - The target environment name. + * @param {string} credentials.protocol - The protocol ('http' or 'https'). + * @param {boolean} credentials.verbose - A flag for verbose logging. + * @returns {Promise<{passwordRequest: Response, sessionCookie: string}|null>} An object with the final response and the session cookie, or null on failure. + */ +async function performUsernamePasswordSteps({ username, password, env, protocol, verbose }) { + const host = getConfig().env[env].host; + const baseUrl = `${protocol}://${host}/Admin/Authentication`; + const loginUrl = `${baseUrl}/Login`; + const passwordUrl = `${baseUrl}/Login/Password`; + + const requestOptions = { method: 'POST', - body: data, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + agent: getAgent(protocol), + redirect: 'manual' + }; + + const loginRequest = await fetch(loginUrl, { + ...requestOptions, + body: new URLSearchParams({ Username: username }) + }); + + if (loginRequest.status !== 302 && !loginRequest.ok) { + if (verbose) { + console.info(loginRequest); + } + + console.log(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution.`) + return null; + } + + const sessionCookie = loginRequest.headers.get('set-cookie'); + if (!sessionCookie) { + console.error('Failed to receive a session cookie after submitting the username.'); + + if (verbose) { + console.info(loginRequest); + } + + return null; + } + + const passwordRequest = await fetch(passwordUrl, { + ...requestOptions, headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + ...requestOptions.headers, + 'Cookie': sessionCookie + }, + body: new URLSearchParams({ Password: password }) + }); + + if (passwordRequest.status !== 302 && !passwordRequest.ok) { + if (verbose) { + console.info(passwordRequest); + } + + console.log(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution.`) + return null; + } + + return { passwordRequest, sessionCookie }; +} + + +/** + * Handles the complete authentication flow for the Normal "Login/Password" method. + * It utilizes the common steps for username/password submission and then extracts + * the final authentication token upon success. + * + * @param {object} credentials - The user's credentials and environment details. + * @param {string} credentials.username - The user's username. + * @param {string} credentials.password - The user's password. + * @param {string} credentials.env - The target environment name. + * @param {string} credentials.protocol - The protocol ('http' or 'https'). + * @param {boolean} credentials.verbose - A flag for verbose logging. + * @returns {Promise} The final authentication token, or undefined on failure. + */ +async function loginWithPassword({ username, password, env, protocol, verbose }) { + const authResult = await performUsernamePasswordSteps({ username, password, env, protocol, verbose }); + + if (!authResult) { + return; + } + + const { passwordRequest } = authResult; + const userAuthCookieHeader = passwordRequest.headers.get('set-cookie'); + + const { user } = parseCookies(userAuthCookieHeader); + + if (!user) { + console.error("Authentication succeeded, but failed to extract user details from the final cookie."); + return; + } + + return await getToken(user, env, protocol, verbose); +} + +/** + * Handles the complete Multi-Factor Authentication (MFA) flow, which consists of + * three sequential steps: Username -> Password -> One-Time Code. + * This function orchestrates the process by calling two helper functions in sequence. + * + * @param {object} credentials - The user's credentials and environment details. + * @param {string} credentials.username - The user's username. + * @param {string} credentials.password - The user's password. + * @param {string} credentials.env - The target environment name. + * @param {string} credentials.protocol - The protocol ('http' or 'https'). + * @param {boolean} credentials.verbose - A flag for verbose logging. + * @returns {Promise} The final authentication token, or undefined on failure. + */ +async function loginWithMFA({ username, password, env, protocol, verbose }) { + const passwordStepResult = await performUsernamePasswordSteps({ username, password, env, protocol, verbose }); + + if (!passwordStepResult) { + return; + } + + const { sessionCookie } = passwordStepResult; + + return await performCodeVerification(sessionCookie, { env, protocol, verbose }); +} + +/** + * Prompts the user for a one-time code and performs the final verification step. + * This function is used by both MFA and Code-only authentication flows. + * + * @param {string} sessionCookie - The session cookie received from a previous authentication step. + * @param {object} options - Environment and logging options. + * @returns {Promise} The final API bearer token, or undefined on failure. + */ +async function performCodeVerification(sessionCookie, { env, protocol, verbose }) { + const promptResult = await yargsInteractive().interactive({ + interactive: { default: true }, + oneTimeCode: { + type: 'input', + describe: 'Please enter the one-time code you received:' + } + }); + + const { oneTimeCode } = promptResult; + if (!oneTimeCode) { + console.error('A one-time code is required to proceed.'); + return; + } + + const host = getConfig().env[env].host; + const verifyUrl = `${protocol}://${host}/Admin/Authentication/Login/VerifyCode`; + + const verifyRequest = await fetch(verifyUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': sessionCookie }, agent: getAgent(protocol), - redirect: "manual" + redirect: 'manual', + body: new URLSearchParams({ OneTimeCode: oneTimeCode }) }); - if (res.ok || res.status == 302) { - let user = parseCookies(res.headers.get('set-cookie')).user; - if (!user) return; - return await getToken(user, env, protocol, verbose) + if (verifyRequest.status !== 302 && !verifyRequest.ok) { + console.error('The provided one-time code could not be verified. It may be incorrect or expired.'); + if (verbose) { + console.info('Server response:', await verifyRequest.text().catch(() => 'Could not read body.')); + } + + return; } - else { - if (verbose) console.info(res) - console.log(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution.`) + + const userAuthCookieHeader = verifyRequest.headers.get('set-cookie'); + const { user } = parseCookies(userAuthCookieHeader); + + if (!user) { + console.error("Code verification succeeded, but failed to extract user details from the final cookie."); + return; } + + return await getToken(user, env, protocol, verbose); +} + +/** + * Handles the complete authentication flow for the Code-only (TOTP) method. + * + * @param {object} credentials - The user's credentials and environment details. + * @param {string} credentials.username - The user's username. + * @param {string} credentials.env - The target environment name. + * @param {string} credentials.protocol - The protocol ('http' or 'https'). + * @param {boolean} credentials.verbose - A flag for verbose logging. + * @returns {Promise} The final authentication token, or undefined on failure. + */ +async function loginWithCode({ username, env, protocol, verbose }) { + const host = getConfig().env[env].host; + const baseUrl = `${protocol}://${host}/Admin/Authentication`; + const loginUrl = `${baseUrl}/Login`; + + const loginRequest = await fetch(loginUrl, { + method: 'POST', + body: new URLSearchParams({ Username: username }), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + agent: getAgent(protocol), + redirect: 'manual' + }); + + if (loginRequest.status !== 302 && !loginRequest.ok) { + if (verbose) { + console.info(loginRequest); + } + + console.log(`Login with code attempt failed (request to '${loginUrl}') for user '${username}'.`); + return; + } + + const sessionCookie = loginRequest.headers.get('set-cookie'); + if (!sessionCookie) { + console.error('Failed to get a session cookie for code-based login.'); + return; + } + + console.log('A one-time code has been sent to your email.'); + return await performCodeVerification(sessionCookie, { env, protocol, verbose }); +} + +/** + * Handles the complete authentication flow for the passwordless "Magic Link" method. + * + * @param {object} credentials - The user's credentials and environment details. + * @param {string} credentials.username - The user's username. + * @param {string} credentials.env - The target environment name. + * @param {string} credentials.protocol - The protocol ('http' or 'https'). + * @param {boolean} credentials.verbose - A flag for verbose logging. + * @returns {Promise} The final authentication token, or undefined on failure. + */ +async function loginWithLink({ username, env, protocol, verbose }) { + const host = getConfig().env[env].host; + const baseUrl = `${protocol}://${host}/Admin/Authentication`; + const loginUrl = `${baseUrl}/Login`; + + const linkRequest = await fetch(loginUrl, { + method: 'POST', + body: new URLSearchParams({ Username: username }), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + agent: getAgent(protocol) + }); + + if (!linkRequest.ok) { + console.error(`Login with link attempt failed (request to '${loginUrl}') for user '${username}'.`); + if (verbose) { + console.info(linkRequest); + } + + return; + } + + console.log('If a user with that username exists, a magic link has been sent to the associated email.'); + + const { secretKey } = await yargsInteractive().interactive({ + interactive: { default: true }, + secretKey: { type: 'input', describe: 'Please find the link in your email and paste the secretKey here:' } + }); + + if (!secretKey) { + console.error('A secret key from the link is required to proceed.'); + return; + } + + const verifyRequest = await fetch(`${loginUrl}/Link?secretKey=${encodeURIComponent(secretKey)}`, { + method: 'GET', + agent: getAgent(protocol), + redirect: 'manual' + }); + + if (verifyRequest.status !== 302 && !verifyRequest.ok) { + console.error('Magic link verification failed. The link may be expired, invalid, or already used.'); + if (verbose) { + console.info(await verifyRequest.text().catch(() => 'Could not read body.')); + } + + return; + } + + const userAuthCookieHeader = verifyRequest.headers.get('set-cookie'); + const { user } = parseCookies(userAuthCookieHeader); + if (!user) return; + + return await getToken(user, env, protocol, verbose); } function parseCookies (cookieHeader) { diff --git a/package-lock.json b/package-lock.json index 689e592..cef50f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dynamicweb/cli", - "version": "1.0.13", + "version": "1.0.15", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@dynamicweb/cli", - "version": "1.0.13", + "version": "1.0.15", "license": "MIT", "dependencies": { "child_process": "^1.0.2", @@ -18,7 +18,7 @@ "log-update": "^6.1.0", "node-fetch": "^3.2.10", "path": "^0.12.7", - "yargs": "^17.5.1", + "yargs": "^17.7.2", "yargs-interactive": "^3.0.1" }, "bin": { diff --git a/package.json b/package.json index ac9eed6..b16c63d 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "log-update": "^6.1.0", "node-fetch": "^3.2.10", "path": "^0.12.7", - "yargs": "^17.5.1", + "yargs": "^17.7.2", "yargs-interactive": "^3.0.1" } } From 61cd8f6ab038326cbc8dc944e8d5f158c325e114 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 6 Aug 2025 20:22:18 +1000 Subject: [PATCH 2/3] Revert changes in the package.json and package-lock.json. --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index cef50f0..689e592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dynamicweb/cli", - "version": "1.0.15", + "version": "1.0.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@dynamicweb/cli", - "version": "1.0.15", + "version": "1.0.13", "license": "MIT", "dependencies": { "child_process": "^1.0.2", @@ -18,7 +18,7 @@ "log-update": "^6.1.0", "node-fetch": "^3.2.10", "path": "^0.12.7", - "yargs": "^17.7.2", + "yargs": "^17.5.1", "yargs-interactive": "^3.0.1" }, "bin": { diff --git a/package.json b/package.json index b16c63d..ac9eed6 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "log-update": "^6.1.0", "node-fetch": "^3.2.10", "path": "^0.12.7", - "yargs": "^17.7.2", + "yargs": "^17.5.1", "yargs-interactive": "^3.0.1" } } From aa25b71fe46b3d6ef35de7fb82a767073d1d1b7b Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Wed, 6 Aug 2025 20:30:55 +1000 Subject: [PATCH 3/3] Cosmetic fixes - changes of console.log text for failed login attempts. --- bin/commands/login.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/commands/login.js b/bin/commands/login.js index 96d9299..eeb792f 100644 --- a/bin/commands/login.js +++ b/bin/commands/login.js @@ -76,14 +76,14 @@ export async function setupUser(argv, env) { const updatedConfig = getConfig(); const updatedEnv = updatedConfig.env[updatedConfig.current.env]; - const finalUser = updatedEnv.users[updatedEnv.current.user]; + const user = updatedEnv.users[updatedEnv.current.user]; - if (!finalUser?.apiKey) { + if (!user?.apiKey) { console.error("Login seemed successful, but failed to retrieve user data. Please try again."); process.exit(); } - return finalUser; + return user; } async function handleLogin(argv) { @@ -437,7 +437,7 @@ async function loginWithCode({ username, env, protocol, verbose }) { console.info(loginRequest); } - console.log(`Login with code attempt failed (request to '${loginUrl}') for user '${username}'.`); + console.log(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution`); return; } @@ -474,7 +474,7 @@ async function loginWithLink({ username, env, protocol, verbose }) { }); if (!linkRequest.ok) { - console.error(`Login with link attempt failed (request to '${loginUrl}') for user '${username}'.`); + console.error(`Login attempt failed with username ${username}, please verify its a valid user in your Dynamicweb solution`); if (verbose) { console.info(linkRequest); }