From 1be4cec2e4ac235ba99ee31e06b1ede114afa39e Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Sun, 23 Aug 2026 19:51:11 +0530 Subject: [PATCH 1/8] feat: Add a new FoD command to request FoD for a multi-factor authentication code --- .../cli/mixin/FoDOutputHelperMixins.java | 5 ++ .../session/cli/cmd/FoDSessionCommands.java | 3 +- .../cmd/FoDSessionRequestMfaCodeCommand.java | 76 +++++++++++++++++++ .../session/helper/FoDMfaDeliveryType.java | 35 +++++++++ .../_common/session/helper/FoDMfaHelper.java | 73 ++++++++++++++++++ .../cli/fod/i18n/FoDMessages.properties | 14 ++++ 6 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaDeliveryType.java create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/output/cli/mixin/FoDOutputHelperMixins.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/output/cli/mixin/FoDOutputHelperMixins.java index 041a2e2ad50..10bcab5da39 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/output/cli/mixin/FoDOutputHelperMixins.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/output/cli/mixin/FoDOutputHelperMixins.java @@ -110,4 +110,9 @@ public static class GetConfigLegacy extends OutputHelperMixins.DetailsNoQuery { public static class UploadFile extends OutputHelperMixins.TableNoQuery { public static final String CMD_NAME = "upload-file"; } + + @Command(aliases = "mfa") + public static class RequestMfaCode extends OutputHelperMixins.TableNoQuery { + public static final String CMD_NAME = "request-mfa-code"; + } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionCommands.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionCommands.java index fc26dd766f1..b1fc638b168 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionCommands.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionCommands.java @@ -21,7 +21,8 @@ subcommands = { FoDSessionListCommand.class, FoDSessionLoginCommand.class, - FoDSessionLogoutCommand.class + FoDSessionLogoutCommand.class, + FoDSessionRequestMfaCodeCommand.class } ) public class FoDSessionCommands extends AbstractContainerCommand { diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java new file mode 100644 index 00000000000..1985e0146b4 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod._common.session.cli.cmd; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.log.LogSensitivityLevel; +import com.fortify.cli.common.log.MaskValue; +import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; +import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.common.session.cli.mixin.UserCredentialOptions; +import com.fortify.cli.fod._common.output.cli.mixin.FoDOutputHelperMixins; +import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; +import com.fortify.cli.fod._common.session.cli.mixin.FoDSessionLoginOptions; +import com.fortify.cli.fod._common.session.helper.FoDMfaDeliveryType; +import com.fortify.cli.fod._common.session.helper.FoDMfaHelper; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Command for requesting a Multi-Factor Authentication (MFA) code via Email or SMS. + * @author Sangamesh Vijaykumar + */ +@Command(name = FoDOutputHelperMixins.RequestMfaCode.CMD_NAME, sortOptions = false) +public class FoDSessionRequestMfaCodeCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { + @Getter @Mixin private FoDOutputHelperMixins.RequestMfaCode outputHelper; + @Mixin private FoDSessionLoginOptions.FoDUrlConfigOptions urlConfigOptions; + @Mixin private UserCredentialOptions userCredentials; + @Option(names = {"-t", "--tenant"}, required = true) + @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") + private String tenant; + @Option(names = {"--delivery-mode", "-m"}, required = true) + private FoDMfaDeliveryType deliveryMode; + + @Override + public JsonNode getJsonNode() { + FoDMfaHelper.requestMfaCode( + urlConfigOptions, + tenant, + userCredentials.getUser(), + userCredentials.getPassword(), + deliveryMode + ); + + String fodUrl = FoDProductHelper.INSTANCE.getBrowserUrl(urlConfigOptions.getUrl()); + + ObjectNode result = com.fortify.cli.common.json.JsonHelper.getObjectMapper().createObjectNode(); + result.put("fodUrl", fodUrl); + result.put("deliveryMode", deliveryMode.name()); + return result; + } + + @Override + public boolean isSingular() { + return true; + } + + @Override + public String getActionCommandResult() { + return "REQUESTED"; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaDeliveryType.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaDeliveryType.java new file mode 100644 index 00000000000..76feefbb135 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaDeliveryType.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod._common.session.helper; + +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Enum representing the delivery types for Multi-Factor Authentication (MFA) codes in Fortify on Demand (FoD). + * @author Sangamesh Vijaykumar + */ +@Reflectable +public enum FoDMfaDeliveryType { + Email("EmailDelivery"), + SMS("SMSDelivery"); + + private final String apiValue; + + FoDMfaDeliveryType(String apiValue) { + this.apiValue = apiValue; + } + + public String getApiValue() { + return apiValue; + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java new file mode 100644 index 00000000000..6a114ad7eed --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.fod._common.session.helper; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.http.proxy.helper.ProxyHelper; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.rest.unirest.HttpHeader; +import com.fortify.cli.common.rest.unirest.UnexpectedHttpResponseException; +import com.fortify.cli.common.rest.unirest.UnirestHelper; +import com.fortify.cli.common.rest.unirest.config.IUrlConfig; +import com.fortify.cli.common.rest.unirest.config.UnirestJsonHeaderConfigurer; +import com.fortify.cli.common.rest.unirest.config.UnirestUnexpectedHttpResponseConfigurer; +import com.fortify.cli.common.rest.unirest.config.UnirestUrlConfigConfigurer; + +import kong.unirest.UnirestInstance; + +/** + * Helper class for requesting Multi-Factor Authentication (MFA) codes in Fortify on Demand (FoD). + * @author Sangamesh Vijaykumar + */ +public class FoDMfaHelper { + + public static final void requestMfaCode(IUrlConfig urlConfig, String tenant, String user, char[] password, FoDMfaDeliveryType deliveryType) { + try ( var unirest = UnirestHelper.createUnirestInstance() ) { + configureUnirest(unirest, urlConfig); + + ObjectNode requestBody = JsonHelper.getObjectMapper().createObjectNode(); + requestBody.put("multiFactorAuthorizationType", deliveryType.getApiValue()); + requestBody.put("username", String.format("%s\\%s", tenant, user)); + requestBody.put("password", String.valueOf(password)); + + unirest.post("/api/v3/multi-factor-authorization-code") + .headerReplace(HttpHeader.ACCEPT, "application/json") + .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") + .body(requestBody) + .asEmpty(); + + //security hardening + java.util.Arrays.fill(password, ' '); // Clear original char array + } catch ( UnexpectedHttpResponseException e ) { + if ( e.getStatus() == 400 ) { + throw new FcliSimpleException( + "MFA is not enabled for this tenant, or the provided credentials are invalid." + + " Contact your FoD administrator, then try again." + ); + } else if ( e.getStatus() == 401 || e.getStatus() == 403 ) { + throw new FcliSimpleException( + "Authentication failed: invalid username, tenant, or password." + ); + } + throw e; + } + } + + private static void configureUnirest(UnirestInstance unirest, IUrlConfig urlConfig) { + UnirestUnexpectedHttpResponseConfigurer.configure(unirest); + UnirestUrlConfigConfigurer.configure(unirest, urlConfig); + ProxyHelper.configureProxy(unirest, "fod", urlConfig.getUrl()); + UnirestJsonHeaderConfigurer.configure(unirest); + } +} diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index c4ba5bd2503..88133108162 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -137,6 +137,20 @@ fcli.fod.session.list.usage.description = This command lists all FoD sessions cr is shown based on locally cached token expiry data. Use '--validate' to verify the actual session \ status against FoD. +fcli.fod.session.request-mfa-code.usage.header = Request Multi-Factor Authentication code via Email or SMS. +fcli.fod.session.request-mfa-code.usage.description = Triggers FoD to send a multi-factor authentication \ + code to the specified delivery method for the given tenant and user. Use the received code with \ + 'fcli fod session login --code ' to complete authentication. +fcli.fod.session.request-mfa-code.url = FoD URL, for example https://emea.fortify.com/. +fcli.fod.session.request-mfa-code.tenant = FoD tenant name. +fcli.fod.session.request-mfa-code.user = FoD username. +fcli.fod.session.request-mfa-code.password = FoD password. +fcli.fod.session.request-mfa-code.delivery-mode = Delivery method for the MFA code. Valid values: ${COMPLETION-CANDIDATES}. +fcli.fod.session.request-mfa-code.header = Repeatable option to add custom HTTP headers in requests to FoD, in format `NAME: VALUE`. +fcli.fod.session.request-mfa-code.output.table.args = fodUrl,deliveryMode +fcli.fod.session.request-mfa-code.output.table.header.fodUrl = FoD URL +fcli.fod.session.request-mfa-code.output.table.header.deliveryMode = Delivery Mode + # fcli fod rest fcli.fod.rest.usage.header = Interact with FoD REST API endpoints. fcli.fod.rest.usage.description = These commands allow for direct interaction with FoD REST API endpoints, \ From b575be70218b937c1914841f3a029a098efc6caa Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 25 Aug 2026 14:19:16 +0530 Subject: [PATCH 2/8] Add support for --totp as an option while retaining flag-based behavior for backward compatibility --- .../cli/cmd/FoDSessionLoginCommand.java | 8 +++--- .../cli/mixin/FoDSessionLoginOptions.java | 26 ++++++++++++++----- .../cli/fod/i18n/FoDMessages.properties | 4 +-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java index 2daed82ee59..f52d4bef20a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java @@ -36,9 +36,11 @@ public class FoDSessionLoginCommand extends AbstractSessionLoginCommand (or -c ) to provide the security code\n" - + " --totp to indicate the code is from a TOTP authenticator app"; + private static final String MFA_GUIDANCE = "If MFA/TOTP is required, provide the security code:\n" + + " --code (or -c ) for an MFA code\n" + + " --totp for a TOTP authenticator code\n" + + " --code --totp (legacy) TOTP code via --code flag\n" + + "Run 'fcli fod session request-mfa-code' to request an MFA code"; private static final String ERROR_WITH_CODE = "Authentication failed. Possible causes:\n" + " - Incorrect username or password\n" diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java index 484ef85b831..44d5ba1cd30 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java @@ -20,6 +20,8 @@ import com.fortify.cli.common.log.MaskValue; import com.fortify.cli.common.rest.cli.mixin.UrlConfigOptions; import com.fortify.cli.common.session.cli.mixin.UserCredentialOptions; +import com.fortify.cli.common.util.DisableTest; +import com.fortify.cli.common.util.DisableTest.TestType; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; import com.fortify.cli.fod._common.session.helper.oauth.IFoDClientCredentials; import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserAuthCode; @@ -61,8 +63,9 @@ public static class FoDUserCredentialOptions extends UserCredentialOptions { @Option(names = {"--code", "-c" }, paramLabel = "", arity = "0..1", interactive = true, echo = false) @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TOTP/MFA CODE") @Getter private String securityCode; - @Option(names = {"--totp" }) - @Getter private boolean isTotp; + @Option(names = {"--totp"}, arity = "0..1", fallbackValue = "true", paramLabel = "") + @DisableTest(TestType.OPT_ARITY_PRESENT) // arity needed for optional-value flag pattern + @Getter private String totp; } public static class FoDClientCredentialOptions implements IFoDClientCredentials { @@ -115,7 +118,10 @@ public final boolean hasClientCredentials() { public boolean hasSecurityCode() { var userCred = getUserCredentialOptions(); - return userCred != null && StringUtils.isNotBlank(userCred.getSecurityCode()); + if (userCred == null) { return false; } + var totp = userCred.getTotp(); + return (StringUtils.isNotBlank(totp) && !"true".equals(totp)) + || StringUtils.isNotBlank(userCred.getSecurityCode()); } public String getSecurityCode() { @@ -125,15 +131,21 @@ public String getSecurityCode() { public boolean isTotp() { var userCred = getUserCredentialOptions(); - return userCred != null && userCred.isTotp(); + return userCred != null && userCred.getTotp() != null; + } + + private String resolveSecurityCode(FoDUserCredentialOptions u) { + var totp = u.getTotp(); + return (totp != null && !"true".equals(totp)) ? totp : u.getSecurityCode(); } public IFoDUserAuthCode getAuthCode() { var u = getUserCredentialOptions(); - if (u == null || StringUtils.isBlank(u.getSecurityCode())) { return null; } + var code = u != null ? resolveSecurityCode(u) : null; + if (StringUtils.isBlank(code)) { return null; } return BasicFoDUserAuthCode.builder() - .securityCode(u.getSecurityCode()) - .isTotp(u.isTotp()) + .securityCode(code) + .isTotp(u.getTotp() != null) .build(); } diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 88133108162..99fdd2d9607 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -123,8 +123,8 @@ fcli.fod.session.login.client-secret = FoD client secret. fcli.fod.session.login.scopes = FoD scopes to request. Default value: ${DEFAULT-VALUE} fcli.fod.session.login.fod-session = Name for this FoD session. Default value: ${DEFAULT-VALUE}. fcli.fod.session.login.header = Repeatable option to add custom HTTP headers in requests to FoD for this session, in format `NAME: VALUE`. -fcli.fod.session.login.code = Security code (TOTP from authenticator or MFA code from email/SMS). -fcli.fod.session.login.totp = Indicates the provided code is TOTP from authenticator app (sets do_totp=true). +fcli.fod.session.login.code = MFA security code (from email/SMS). Use 'fcli fod session request-mfa-code' to request a code. +fcli.fod.session.login.totp = TOTP code from an authenticator app. When used as a flag without a value (legacy: --code --totp), the TOTP code must be provided via --code. fcli.fod.session.logout.usage.header = Terminate FoD session. fcli.fod.session.logout.usage.description = This command terminates an FoD session previously created \ From 835294306cc79cfe6f11dfd5249096bc07f29fd8 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:39:29 +0200 Subject: [PATCH 3/8] Update error text (mention email/SMS, remove legacy approach) --- .../fod/_common/session/cli/cmd/FoDSessionLoginCommand.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java index f52d4bef20a..0bfd655545d 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java @@ -37,10 +37,9 @@ public class FoDSessionLoginCommand extends AbstractSessionLoginCommand (or -c ) for an MFA code\n" + + " --code (or -c ) for an email/SMS MFA code\n" + " --totp for a TOTP authenticator code\n" - + " --code --totp (legacy) TOTP code via --code flag\n" - + "Run 'fcli fod session request-mfa-code' to request an MFA code"; + + "Run 'fcli fod session request-mfa-code' to request an email/SMS MFA code"; private static final String ERROR_WITH_CODE = "Authentication failed. Possible causes:\n" + " - Incorrect username or password\n" From d6284a7d9d147073bef840dbbfcd36f0842b1162 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:59:08 +0200 Subject: [PATCH 4/8] chore: Refactor user credential options for better re-use --- .../cmd/FoDSessionRequestMfaCodeCommand.java | 12 +--- .../cli/mixin/FoDSessionLoginOptions.java | 59 ++++++++++++------- .../_common/session/helper/FoDMfaHelper.java | 9 +-- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java index 1985e0146b4..b8181b75c9a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java @@ -14,12 +14,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.log.LogSensitivityLevel; -import com.fortify.cli.common.log.MaskValue; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; -import com.fortify.cli.common.session.cli.mixin.UserCredentialOptions; import com.fortify.cli.fod._common.output.cli.mixin.FoDOutputHelperMixins; import com.fortify.cli.fod._common.rest.helper.FoDProductHelper; import com.fortify.cli.fod._common.session.cli.mixin.FoDSessionLoginOptions; @@ -39,10 +36,7 @@ public class FoDSessionRequestMfaCodeCommand extends AbstractOutputCommand implements IJsonNodeSupplier, IActionCommandResultSupplier { @Getter @Mixin private FoDOutputHelperMixins.RequestMfaCode outputHelper; @Mixin private FoDSessionLoginOptions.FoDUrlConfigOptions urlConfigOptions; - @Mixin private UserCredentialOptions userCredentials; - @Option(names = {"-t", "--tenant"}, required = true) - @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") - private String tenant; + @Mixin private FoDSessionLoginOptions.FoDUserCredentialOptions userCredentials; @Option(names = {"--delivery-mode", "-m"}, required = true) private FoDMfaDeliveryType deliveryMode; @@ -50,9 +44,7 @@ public class FoDSessionRequestMfaCodeCommand extends AbstractOutputCommand imple public JsonNode getJsonNode() { FoDMfaHelper.requestMfaCode( urlConfigOptions, - tenant, - userCredentials.getUser(), - userCredentials.getPassword(), + userCredentials, deliveryMode ); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java index 44d5ba1cd30..62107a52dfe 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java @@ -51,15 +51,19 @@ public static class FoDAuthOptions { public static class FoDCredentialOptions { @ArgGroup(exclusive = false, multiplicity = "1", order = 1) - @Getter private FoDUserCredentialOptions userCredentialOptions = new FoDUserCredentialOptions(); + @Getter private FoDUserCredentialWithMfaOptions userCredentialWithMfaOptions = new FoDUserCredentialWithMfaOptions(); @ArgGroup(exclusive = false, multiplicity = "1", order = 2) @Getter private FoDClientCredentialOptions clientCredentialOptions = new FoDClientCredentialOptions(); } - public static class FoDUserCredentialOptions extends UserCredentialOptions { - @Option(names = {"-t", "--tenant"}, required = true) - @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") - @Getter private String tenant; + public static class FoDUserCredentialWithMfaOptions { + @ArgGroup(exclusive = false, multiplicity = "1", order = 1) + @Getter private FoDUserCredentialOptions userCredentialOptions = new FoDUserCredentialOptions(); + @ArgGroup(exclusive = false, multiplicity = "0..1", order = 2) + @Getter private FoDMfaOptions mfaOptions = new FoDMfaOptions(); + } + + public static class FoDMfaOptions { @Option(names = {"--code", "-c" }, paramLabel = "", arity = "0..1", interactive = true, echo = false) @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TOTP/MFA CODE") @Getter private String securityCode; @@ -68,6 +72,12 @@ public static class FoDUserCredentialOptions extends UserCredentialOptions { @Getter private String totp; } + public static class FoDUserCredentialOptions extends UserCredentialOptions implements IFoDUserCredentials { + @Option(names = {"-t", "--tenant"}, required = true) + @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") + @Getter private String tenant; + } + public static class FoDClientCredentialOptions implements IFoDClientCredentials { @Option(names = {"--client-id"}, required = true) @MaskValue(sensitivity = LogSensitivityLevel.medium, description = "FOD CLIENT ID") @@ -80,7 +90,16 @@ public static class FoDClientCredentialOptions implements IFoDClientCredentials public FoDUserCredentialOptions getUserCredentialOptions() { return Optional.ofNullable(authOptions) .map(FoDAuthOptions::getCredentialOptions) - .map(FoDCredentialOptions::getUserCredentialOptions) + .map(FoDCredentialOptions::getUserCredentialWithMfaOptions) + .map(FoDUserCredentialWithMfaOptions::getUserCredentialOptions) + .orElse(null); + } + + private FoDMfaOptions getMfaOptions() { + return Optional.ofNullable(authOptions) + .map(FoDAuthOptions::getCredentialOptions) + .map(FoDCredentialOptions::getUserCredentialWithMfaOptions) + .map(FoDUserCredentialWithMfaOptions::getMfaOptions) .orElse(null); } @@ -117,35 +136,35 @@ public final boolean hasClientCredentials() { } public boolean hasSecurityCode() { - var userCred = getUserCredentialOptions(); - if (userCred == null) { return false; } - var totp = userCred.getTotp(); + var mfaOptions = getMfaOptions(); + if (mfaOptions == null) { return false; } + var totp = mfaOptions.getTotp(); return (StringUtils.isNotBlank(totp) && !"true".equals(totp)) - || StringUtils.isNotBlank(userCred.getSecurityCode()); + || StringUtils.isNotBlank(mfaOptions.getSecurityCode()); } public String getSecurityCode() { - var userCred = getUserCredentialOptions(); - return userCred != null ? userCred.getSecurityCode() : null; + var mfaOptions = getMfaOptions(); + return mfaOptions != null ? mfaOptions.getSecurityCode() : null; } public boolean isTotp() { - var userCred = getUserCredentialOptions(); - return userCred != null && userCred.getTotp() != null; + var mfaOptions = getMfaOptions(); + return mfaOptions != null && mfaOptions.getTotp() != null; } - private String resolveSecurityCode(FoDUserCredentialOptions u) { - var totp = u.getTotp(); - return (totp != null && !"true".equals(totp)) ? totp : u.getSecurityCode(); + private String resolveSecurityCode(FoDMfaOptions mfaOptions) { + var totp = mfaOptions.getTotp(); + return (totp != null && !"true".equals(totp)) ? totp : mfaOptions.getSecurityCode(); } public IFoDUserAuthCode getAuthCode() { - var u = getUserCredentialOptions(); - var code = u != null ? resolveSecurityCode(u) : null; + var mfaOptions = getMfaOptions(); + var code = mfaOptions != null ? resolveSecurityCode(mfaOptions) : null; if (StringUtils.isBlank(code)) { return null; } return BasicFoDUserAuthCode.builder() .securityCode(code) - .isTotp(u.getTotp() != null) + .isTotp(mfaOptions != null && mfaOptions.getTotp() != null) .build(); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java index 6a114ad7eed..4f06a4dadfa 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java @@ -23,6 +23,7 @@ import com.fortify.cli.common.rest.unirest.config.UnirestJsonHeaderConfigurer; import com.fortify.cli.common.rest.unirest.config.UnirestUnexpectedHttpResponseConfigurer; import com.fortify.cli.common.rest.unirest.config.UnirestUrlConfigConfigurer; +import com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; import kong.unirest.UnirestInstance; @@ -32,14 +33,14 @@ */ public class FoDMfaHelper { - public static final void requestMfaCode(IUrlConfig urlConfig, String tenant, String user, char[] password, FoDMfaDeliveryType deliveryType) { + public static final void requestMfaCode(IUrlConfig urlConfig, IFoDUserCredentials userCredentials, FoDMfaDeliveryType deliveryType) { try ( var unirest = UnirestHelper.createUnirestInstance() ) { configureUnirest(unirest, urlConfig); ObjectNode requestBody = JsonHelper.getObjectMapper().createObjectNode(); requestBody.put("multiFactorAuthorizationType", deliveryType.getApiValue()); - requestBody.put("username", String.format("%s\\%s", tenant, user)); - requestBody.put("password", String.valueOf(password)); + requestBody.put("username", String.format("%s\\%s", userCredentials.getTenant(), userCredentials.getUser())); + requestBody.put("password", String.valueOf(userCredentials.getPassword())); unirest.post("/api/v3/multi-factor-authorization-code") .headerReplace(HttpHeader.ACCEPT, "application/json") @@ -48,7 +49,7 @@ public static final void requestMfaCode(IUrlConfig urlConfig, String tenant, Str .asEmpty(); //security hardening - java.util.Arrays.fill(password, ' '); // Clear original char array + java.util.Arrays.fill(userCredentials.getPassword(), ' '); // Clear original char array } catch ( UnexpectedHttpResponseException e ) { if ( e.getStatus() == 400 ) { throw new FcliSimpleException( From 555fb52b0bd4b30868ef85d9065cd0e8fa321f3a Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:00:47 +0200 Subject: [PATCH 5/8] chore: Don't clear char[] --- .../fortify/cli/fod/_common/session/helper/FoDMfaHelper.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java index 4f06a4dadfa..69d280aa5de 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java @@ -47,9 +47,6 @@ public static final void requestMfaCode(IUrlConfig urlConfig, IFoDUserCredential .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") .body(requestBody) .asEmpty(); - - //security hardening - java.util.Arrays.fill(userCredentials.getPassword(), ' '); // Clear original char array } catch ( UnexpectedHttpResponseException e ) { if ( e.getStatus() == 400 ) { throw new FcliSimpleException( From cc491c2982c5eb03a67ee48af919204b1057396e Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:12 +0200 Subject: [PATCH 6/8] chore: Improve MFA option handling & prompting --- .../cli/mixin/FoDSessionLoginOptions.java | 76 +++++++++++++------ 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java index 62107a52dfe..5bdc5ee615a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java @@ -16,6 +16,7 @@ import org.apache.commons.lang3.StringUtils; +import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.log.LogSensitivityLevel; import com.fortify.cli.common.log.MaskValue; import com.fortify.cli.common.rest.cli.mixin.UrlConfigOptions; @@ -64,12 +65,56 @@ public static class FoDUserCredentialWithMfaOptions { } public static class FoDMfaOptions { - @Option(names = {"--code", "-c" }, paramLabel = "", arity = "0..1", interactive = true, echo = false) + // Marker value picocli assigns when the option is given as a bare flag (no inline value). + private static final String FLAG = "true"; + + // Not interactive: prompting is handled in computeMfaCode() below, since whether/what to + // prompt for depends on both --code and --totp together (see computeMfaCode() javadoc). + @Option(names = {"--code", "-c" }, paramLabel = "", arity = "0..1", fallbackValue = FLAG) + @DisableTest(TestType.OPT_ARITY_PRESENT) // arity needed for optional-value flag pattern @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TOTP/MFA CODE") @Getter private String securityCode; - @Option(names = {"--totp"}, arity = "0..1", fallbackValue = "true", paramLabel = "") + @Option(names = {"--totp"}, arity = "0..1", fallbackValue = FLAG, paramLabel = "") @DisableTest(TestType.OPT_ARITY_PRESENT) // arity needed for optional-value flag pattern @Getter private String totp; + + /** Whether --totp was specified in any form (bare flag or with a value). */ + public boolean isTotp() { + return totp != null; + } + + /** + * Resolves the effective MFA code, supporting both the legacy {@code --code --totp} + * and the new {@code --totp } usage. An explicit value on either option is used as-is; + * otherwise, if given as a bare flag, prompts interactively for the code, preferring --totp's + * prompt over --code's if both are given bare (--totp implies the code is TOTP, not email/SMS). + * Result is cached, so the prompt (if any) only happens once. + */ + @Getter(lazy = true) private final String mfaCode = computeMfaCode(); + + private String computeMfaCode() { + var explicitTotp = valueOrNull(totp); + var explicitCode = valueOrNull(securityCode); + if (explicitTotp != null) { return explicitTotp; } + if (explicitCode != null) { return explicitCode; } + if (FLAG.equals(totp)) { return promptFor("TOTP code: "); } + if (FLAG.equals(securityCode)) { + return promptFor("MFA security code (from email/SMS; use 'fcli fod session request-mfa-code' to request one): "); + } + return null; + } + + private static String valueOrNull(String value) { + return value == null || FLAG.equals(value) ? null : value; + } + + private String promptFor(String prompt) { + var console = System.console(); + if (console == null) { + throw new FcliSimpleException("No console available to prompt for MFA code; specify --totp or --code instead"); + } + return console.readLine(prompt); + } } public static class FoDUserCredentialOptions extends UserCredentialOptions implements IFoDUserCredentials { @@ -137,34 +182,15 @@ public final boolean hasClientCredentials() { public boolean hasSecurityCode() { var mfaOptions = getMfaOptions(); - if (mfaOptions == null) { return false; } - var totp = mfaOptions.getTotp(); - return (StringUtils.isNotBlank(totp) && !"true".equals(totp)) - || StringUtils.isNotBlank(mfaOptions.getSecurityCode()); - } - - public String getSecurityCode() { - var mfaOptions = getMfaOptions(); - return mfaOptions != null ? mfaOptions.getSecurityCode() : null; - } - - public boolean isTotp() { - var mfaOptions = getMfaOptions(); - return mfaOptions != null && mfaOptions.getTotp() != null; - } - - private String resolveSecurityCode(FoDMfaOptions mfaOptions) { - var totp = mfaOptions.getTotp(); - return (totp != null && !"true".equals(totp)) ? totp : mfaOptions.getSecurityCode(); + return mfaOptions != null && StringUtils.isNotBlank(mfaOptions.getMfaCode()); } public IFoDUserAuthCode getAuthCode() { var mfaOptions = getMfaOptions(); - var code = mfaOptions != null ? resolveSecurityCode(mfaOptions) : null; - if (StringUtils.isBlank(code)) { return null; } + if (mfaOptions == null || StringUtils.isBlank(mfaOptions.getMfaCode())) { return null; } return BasicFoDUserAuthCode.builder() - .securityCode(code) - .isTotp(mfaOptions != null && mfaOptions.getTotp() != null) + .securityCode(mfaOptions.getMfaCode()) + .isTotp(mfaOptions.isTotp()) .build(); } From cc16d3aa1b52aebb2a84054d18dfd58e6f9f9988 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:56:24 +0200 Subject: [PATCH 7/8] chore: Allow multiple MFA delivery methods, defaulting to all --- .../cmd/FoDSessionRequestMfaCodeCommand.java | 25 ++++++++++++------- .../cli/fod/i18n/FoDMessages.properties | 8 +++--- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java index b8181b75c9a..1efcdca6089 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java @@ -13,7 +13,9 @@ package com.fortify.cli.fod._common.session.cli.cmd; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.cmd.AbstractOutputCommand; import com.fortify.cli.common.output.cli.cmd.IJsonNodeSupplier; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -37,22 +39,27 @@ public class FoDSessionRequestMfaCodeCommand extends AbstractOutputCommand imple @Getter @Mixin private FoDOutputHelperMixins.RequestMfaCode outputHelper; @Mixin private FoDSessionLoginOptions.FoDUrlConfigOptions urlConfigOptions; @Mixin private FoDSessionLoginOptions.FoDUserCredentialOptions userCredentials; - @Option(names = {"--delivery-mode", "-m"}, required = true) - private FoDMfaDeliveryType deliveryMode; + @Option(names = {"--delivery-modes", "-m"}, split = ",") + private FoDMfaDeliveryType[] deliveryModes = FoDMfaDeliveryType.values(); @Override public JsonNode getJsonNode() { - FoDMfaHelper.requestMfaCode( - urlConfigOptions, - userCredentials, - deliveryMode - ); + for (FoDMfaDeliveryType deliveryMode : deliveryModes) { + FoDMfaHelper.requestMfaCode( + urlConfigOptions, + userCredentials, + deliveryMode + ); + } String fodUrl = FoDProductHelper.INSTANCE.getBrowserUrl(urlConfigOptions.getUrl()); - ObjectNode result = com.fortify.cli.common.json.JsonHelper.getObjectMapper().createObjectNode(); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("fodUrl", fodUrl); - result.put("deliveryMode", deliveryMode.name()); + ArrayNode requestedDeliveryModes = result.putArray("deliveryModes"); + for (FoDMfaDeliveryType deliveryMode : deliveryModes) { + requestedDeliveryModes.add(deliveryMode.name()); + } return result; } diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 99fdd2d9607..8c7926373ac 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -139,17 +139,17 @@ fcli.fod.session.list.usage.description = This command lists all FoD sessions cr fcli.fod.session.request-mfa-code.usage.header = Request Multi-Factor Authentication code via Email or SMS. fcli.fod.session.request-mfa-code.usage.description = Triggers FoD to send a multi-factor authentication \ - code to the specified delivery method for the given tenant and user. Use the received code with \ + code to the specified delivery methods for the given tenant and user. Use the received code with \ 'fcli fod session login --code ' to complete authentication. fcli.fod.session.request-mfa-code.url = FoD URL, for example https://emea.fortify.com/. fcli.fod.session.request-mfa-code.tenant = FoD tenant name. fcli.fod.session.request-mfa-code.user = FoD username. fcli.fod.session.request-mfa-code.password = FoD password. -fcli.fod.session.request-mfa-code.delivery-mode = Delivery method for the MFA code. Valid values: ${COMPLETION-CANDIDATES}. +fcli.fod.session.request-mfa-code.delivery-modes = Delivery methods for the MFA code. Valid values: ${COMPLETION-CANDIDATES}. Defaults to all available delivery methods. fcli.fod.session.request-mfa-code.header = Repeatable option to add custom HTTP headers in requests to FoD, in format `NAME: VALUE`. -fcli.fod.session.request-mfa-code.output.table.args = fodUrl,deliveryMode +fcli.fod.session.request-mfa-code.output.table.args = fodUrl,deliveryModes fcli.fod.session.request-mfa-code.output.table.header.fodUrl = FoD URL -fcli.fod.session.request-mfa-code.output.table.header.deliveryMode = Delivery Mode +fcli.fod.session.request-mfa-code.output.table.header.deliveryModes = Delivery Modes # fcli fod rest fcli.fod.rest.usage.header = Interact with FoD REST API endpoints. From 185efb4e70b961c93d0a713f55f32ac9ae312181 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:59:12 +0200 Subject: [PATCH 8/8] chore: Simplify option description --- .../resources/com/fortify/cli/fod/i18n/FoDMessages.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 8c7926373ac..11b100091ed 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -123,8 +123,8 @@ fcli.fod.session.login.client-secret = FoD client secret. fcli.fod.session.login.scopes = FoD scopes to request. Default value: ${DEFAULT-VALUE} fcli.fod.session.login.fod-session = Name for this FoD session. Default value: ${DEFAULT-VALUE}. fcli.fod.session.login.header = Repeatable option to add custom HTTP headers in requests to FoD for this session, in format `NAME: VALUE`. -fcli.fod.session.login.code = MFA security code (from email/SMS). Use 'fcli fod session request-mfa-code' to request a code. -fcli.fod.session.login.totp = TOTP code from an authenticator app. When used as a flag without a value (legacy: --code --totp), the TOTP code must be provided via --code. +fcli.fod.session.login.code = MFA security code from email/SMS. Use 'fcli fod session request-mfa-code' to request a code. +fcli.fod.session.login.totp = TOTP code from an authenticator app. fcli.fod.session.logout.usage.header = Terminate FoD session. fcli.fod.session.logout.usage.description = This command terminates an FoD session previously created \