Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although it's nice to have a short alias, I'm not 100% sure whether I like this alias ('mfa' by itself has a meaning, but doesn't describe what this command does, and also maybe in the future we need to add other MFA-related commands).

Related, can we think of a shorter full command name? I think the option on the login command is now --code, so maybe we can name the command request-code? Any other word for 'request'? Maybe ask Shajaan for suggestions?

Maybe we should reconsider this altogether, thinking first about whether we should improve the MFA handling on the login command. Some potential approaches:

  • Restructure MFA options on login command: --mfa=<code> and --totp=<code>, then have separate mfa command to request MFA code (thus command name matching the login --mfa option name)
  • Integrate MFA request into login command, i.e., --mfa[=<code>], if no code given, we check whether there's a cached code; if not (or cached code results in denied exception due to being expired), we send the 'request MFA' request and then prompt the user to enter the MFA code

In other words, maybe we should take a step back to decide on the most user-friendly approach (while maintaining backward compatibility, possibly marking existing functionality as deprecated).

public static class RequestMfaCode extends OutputHelperMixins.TableNoQuery {
public static final String CMD_NAME = "request-mfa-code";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
subcommands = {
FoDSessionListCommand.class,
FoDSessionLoginCommand.class,
FoDSessionLogoutCommand.class
FoDSessionLogoutCommand.class,
FoDSessionRequestMfaCodeCommand.class
}
)
public class FoDSessionCommands extends AbstractContainerCommand {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ public class FoDSessionLoginCommand extends AbstractSessionLoginCommand<FoDSessi
@Mixin private FoDSessionLoginOptions loginOptions;
@Mixin private FoDUnirestInstanceSupplierMixin unirestInstanceSupplierMixin;

private static final String MFA_GUIDANCE = "If MFA is required, provide the security code:\n"
+ " --code <code> (or -c <code>) 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 <code> (or -c <code>) for an email/SMS MFA code\n"
+ " --totp <code> 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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = "<code>", 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 = "<totp>")
@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 <code> --totp}
* and the new {@code --totp <code>} 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 <code> or --code <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 = "<code>", 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 {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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 <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, \
Expand Down
Loading