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/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/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/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/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..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 \ @@ -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, \