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..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 @@ -36,9 +36,10 @@ 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 email/SMS MFA code\n" + + " --totp for a TOTP authenticator code\n" + + "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" 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..1efcdca6089 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionRequestMfaCodeCommand.java @@ -0,0 +1,75 @@ +/* + * 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.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; +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 FoDSessionLoginOptions.FoDUserCredentialOptions userCredentials; + @Option(names = {"--delivery-modes", "-m"}, split = ",") + private FoDMfaDeliveryType[] deliveryModes = FoDMfaDeliveryType.values(); + + @Override + public JsonNode getJsonNode() { + for (FoDMfaDeliveryType deliveryMode : deliveryModes) { + FoDMfaHelper.requestMfaCode( + urlConfigOptions, + userCredentials, + deliveryMode + ); + } + + String fodUrl = FoDProductHelper.INSTANCE.getBrowserUrl(urlConfigOptions.getUrl()); + + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + result.put("fodUrl", fodUrl); + ArrayNode requestedDeliveryModes = result.putArray("deliveryModes"); + for (FoDMfaDeliveryType deliveryMode : deliveryModes) { + requestedDeliveryModes.add(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..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,10 +16,13 @@ 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; 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; @@ -49,20 +52,75 @@ 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 { + 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 { + // 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 = 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 { @Option(names = {"-t", "--tenant"}, required = true) @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") @Getter private String tenant; - @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; } public static class FoDClientCredentialOptions implements IFoDClientCredentials { @@ -77,7 +135,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); } @@ -114,26 +181,16 @@ public final boolean hasClientCredentials() { } public boolean hasSecurityCode() { - var userCred = getUserCredentialOptions(); - return userCred != null && StringUtils.isNotBlank(userCred.getSecurityCode()); - } - - public String getSecurityCode() { - var userCred = getUserCredentialOptions(); - return userCred != null ? userCred.getSecurityCode() : null; - } - - public boolean isTotp() { - var userCred = getUserCredentialOptions(); - return userCred != null && userCred.isTotp(); + var mfaOptions = getMfaOptions(); + return mfaOptions != null && StringUtils.isNotBlank(mfaOptions.getMfaCode()); } public IFoDUserAuthCode getAuthCode() { - var u = getUserCredentialOptions(); - if (u == null || StringUtils.isBlank(u.getSecurityCode())) { return null; } + var mfaOptions = getMfaOptions(); + if (mfaOptions == null || StringUtils.isBlank(mfaOptions.getMfaCode())) { return null; } return BasicFoDUserAuthCode.builder() - .securityCode(u.getSecurityCode()) - .isTotp(u.isTotp()) + .securityCode(mfaOptions.getMfaCode()) + .isTotp(mfaOptions.isTotp()) .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..69d280aa5de --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/helper/FoDMfaHelper.java @@ -0,0 +1,71 @@ +/* + * 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 com.fortify.cli.fod._common.session.helper.oauth.IFoDUserCredentials; + +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, 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", userCredentials.getTenant(), userCredentials.getUser())); + requestBody.put("password", String.valueOf(userCredentials.getPassword())); + + unirest.post("/api/v3/multi-factor-authorization-code") + .headerReplace(HttpHeader.ACCEPT, "application/json") + .headerReplace(HttpHeader.CONTENT_TYPE, "application/json") + .body(requestBody) + .asEmpty(); + } 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..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 = 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. 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 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-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,deliveryModes +fcli.fod.session.request-mfa-code.output.table.header.fodUrl = FoD URL +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. fcli.fod.rest.usage.description = These commands allow for direct interaction with FoD REST API endpoints, \