Skip to content

Commit

Permalink
Improve question MFA prompt (#458)
Browse files Browse the repository at this point in the history
- Show security question
- Mask out answer
  • Loading branch information
boarnoah authored Jul 11, 2024
1 parent 023ed47 commit 78f403b
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 93 deletions.
152 changes: 81 additions & 71 deletions src/D2L.Bmx/ConsolePrompter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ internal interface IConsolePrompter {
string PromptAccount( string[] accounts );
string PromptRole( string[] roles );
OktaMfaFactor SelectMfa( OktaMfaFactor[] mfaOptions );
string GetMfaResponse( string mfaInputPrompt );
string GetMfaResponse( string mfaInputPrompt, bool maskInput );
}

internal class ConsolePrompter : IConsolePrompter {
Expand Down Expand Up @@ -64,73 +64,9 @@ string IConsolePrompter.PromptUser( bool allowEmptyInput ) {
}

string IConsolePrompter.PromptPassword( string user, string org ) {
Func<char> readKey;
if( IS_WINDOWS ) {
// On Windows, Console.ReadKey calls native console API, and will fail without a console attached
if( Console.IsInputRedirected ) {
Console.Error.WriteLine( """
====== WARNING ======
Input to BMX is redirected. Password input may be displayed on screen!
If you're using mintty (with Git Bash, Cygwin, MSYS2 etc.), consider switching
to Windows Terminal for a better experience.
If you must use mintty, prefix your bmx command with 'winpty '.
=====================
""" );
readKey = () => (char)_stdinReader.Read();
} else {
readKey = () => Console.ReadKey( intercept: true ).KeyChar;
}
} else {
readKey = () => (char)_stdinReader.Read();
}

Console.Error.WriteLine( $"{ParameterDescriptions.Org}: {org}" );
Console.Error.WriteLine( $"{ParameterDescriptions.User}: {user}" );
Console.Error.Write( $"{ParameterDescriptions.Password}: " );

string? originalTerminalSettings = null;
try {
if( !IS_WINDOWS ) {
originalTerminalSettings = GetCurrentTerminalSettings();
EnableTerminalRawMode();
}

var passwordBuilder = new StringBuilder();
while( true ) {
char key = readKey();

if( key == CTRL_C ) {
// Ctrl+C should terminate the program.
// Using an empty string as the exception message because this message is displayed to the user,
// but the user doesn't need to see anything when they themselves ended the program.
throw new BmxException( string.Empty );
}
if( key == '\n' || key == '\r' ) {
// when the terminal is in raw mode, writing \r is needed to start the new line properly
Console.Error.Write( "\r\n" );
return passwordBuilder.ToString();
}

if( key == CTRL_U ) {
string moveLeftString = new( '\b', passwordBuilder.Length );
string emptyString = new( ' ', passwordBuilder.Length );
Console.Error.Write( moveLeftString + emptyString + moveLeftString );
passwordBuilder.Clear();
} else
// The backspace key is received as the DEL character in raw mode
if( ( key == '\b' || key == DEL ) && passwordBuilder.Length > 0 ) {
Console.Error.Write( "\b \b" );
passwordBuilder.Length--;
} else if( !char.IsControl( key ) ) {
Console.Error.Write( '*' );
passwordBuilder.Append( key );
}
}
} finally {
if( !IS_WINDOWS && !string.IsNullOrEmpty( originalTerminalSettings ) ) {
SetTerminalSettings( originalTerminalSettings );
}
}
return GetMaskedInput( $"{ParameterDescriptions.Password}: " );
}

int? IConsolePrompter.PromptDuration() {
Expand Down Expand Up @@ -194,12 +130,12 @@ OktaMfaFactor IConsolePrompter.SelectMfa( OktaMfaFactor[] mfaOptions ) {
}

if( mfaOptions.Length == 1 ) {
Console.Error.WriteLine( $"MFA method: {mfaOptions[0].Provider}-{mfaOptions[0].FactorType}" );
Console.Error.WriteLine( $"MFA method: {mfaOptions[0].Provider} : {mfaOptions[0].FactorName}" );
return mfaOptions[0];
}

for( int i = 0; i < mfaOptions.Length; i++ ) {
Console.Error.WriteLine( $"[{i + 1}] {mfaOptions[i].Provider}-{mfaOptions[i].FactorType}" );
Console.Error.WriteLine( $"[{i + 1}] {mfaOptions[i].Provider} : {mfaOptions[i].FactorName}" );
}
Console.Error.Write( "Select an available MFA option: " );
if( !int.TryParse( _stdinReader.ReadLine(), out int index ) || index > mfaOptions.Length || index < 1 ) {
Expand All @@ -208,16 +144,90 @@ OktaMfaFactor IConsolePrompter.SelectMfa( OktaMfaFactor[] mfaOptions ) {
return mfaOptions[index - 1];
}

string IConsolePrompter.GetMfaResponse( string mfaInputPrompt ) {
Console.Error.Write( $"{mfaInputPrompt}: " );
string? mfaInput = _stdinReader.ReadLine();
string IConsolePrompter.GetMfaResponse( string mfaInputPrompt, bool maskInput ) {
string? mfaInput;

if( maskInput ) {
mfaInput = GetMaskedInput( $"{mfaInputPrompt}: " );
} else {
Console.Error.Write( $"{mfaInputPrompt}: " );
mfaInput = _stdinReader.ReadLine();
}

if( mfaInput is not null ) {
return mfaInput;
}
throw new BmxException( "Invalid MFA Input" );
}

private string GetMaskedInput( string prompt ) {
Func<char> readKey;
if( IS_WINDOWS ) {
// On Windows, Console.ReadKey calls native console API, and will fail without a console attached
if( Console.IsInputRedirected ) {
Console.Error.WriteLine( """
====== WARNING ======
Input to BMX is redirected. Input may be displayed on screen!
If you're using mintty (with Git Bash, Cygwin, MSYS2 etc.), consider switching
to Windows Terminal for a better experience.
If you must use mintty, prefix your bmx command with 'winpty '.
=====================
""" );
readKey = () => (char)_stdinReader.Read();
} else {
readKey = () => Console.ReadKey( intercept: true ).KeyChar;
}
} else {
readKey = () => (char)_stdinReader.Read();
}

Console.Error.Write( prompt );

string? originalTerminalSettings = null;
try {
if( !IS_WINDOWS ) {
originalTerminalSettings = GetCurrentTerminalSettings();
EnableTerminalRawMode();
}

var passwordBuilder = new StringBuilder();
while( true ) {
char key = readKey();

if( key == CTRL_C ) {
// Ctrl+C should terminate the program.
// Using an empty string as the exception message because this message is displayed to the user,
// but the user doesn't need to see anything when they themselves ended the program.
throw new BmxException( string.Empty );
}
if( key == '\n' || key == '\r' ) {
// when the terminal is in raw mode, writing \r is needed to start the new line properly
Console.Error.Write( "\r\n" );
return passwordBuilder.ToString();
}

if( key == CTRL_U ) {
string moveLeftString = new( '\b', passwordBuilder.Length );
string emptyString = new( ' ', passwordBuilder.Length );
Console.Error.Write( moveLeftString + emptyString + moveLeftString );
passwordBuilder.Clear();
} else
// The backspace key is received as the DEL character in raw mode
if( ( key == '\b' || key == DEL ) && passwordBuilder.Length > 0 ) {
Console.Error.Write( "\b \b" );
passwordBuilder.Length--;
} else if( !char.IsControl( key ) ) {
Console.Error.Write( '*' );
passwordBuilder.Append( key );
}
}
} finally {
if( !IS_WINDOWS && !string.IsNullOrEmpty( originalTerminalSettings ) ) {
SetTerminalSettings( originalTerminalSettings );
}
}
}

private static string GetCurrentTerminalSettings() {
var startInfo = new ProcessStartInfo( "stty" );
startInfo.ArgumentList.Add( "-g" );
Expand Down
1 change: 1 addition & 0 deletions src/D2L.Bmx/D2L.Bmx.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="HtmlAgilityPack" Version="1.11.57" />
<PackageReference Include="ini-parser-netstandard" Version="2.5.2" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.Text.Json" Version="9.0.0-preview.5.24306.7" />
</ItemGroup>

</Project>
5 changes: 4 additions & 1 deletion src/D2L.Bmx/JsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

namespace D2L.Bmx;

[JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase )]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
AllowOutOfOrderMetadataProperties = true
)]
[JsonSerializable( typeof( AuthenticateRequest ) )]
[JsonSerializable( typeof( IssueMfaChallengeRequest ) )]
[JsonSerializable( typeof( VerifyMfaChallengeResponseRequest ) )]
Expand Down
77 changes: 72 additions & 5 deletions src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,80 @@ internal record AuthenticateResponseEmbedded(
OktaMfaFactor[]? Factors
);

internal record OktaMfaFactor(
string Id,
string FactorType,
string Provider,
string VendorName
[JsonPolymorphic(
TypeDiscriminatorPropertyName = "factorType",
IgnoreUnrecognizedTypeDiscriminators = true
)]
[JsonDerivedType( typeof( OktaMfaQuestionFactor ), OktaMfaQuestionFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaTokenFactor ), OktaMfaTokenFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaHardwareTokenFactor ), OktaMfaHardwareTokenFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaSoftwareTotpFactor ), OktaMfaSoftwareTotpFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaHotpFactor ), OktaMfaHotpFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaSmsFactor ), OktaMfaSmsFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaCallFactor ), OktaMfaCallFactor.FactorType )]
[JsonDerivedType( typeof( OktaMfaEmailFactor ), OktaMfaEmailFactor.FactorType )]
internal record OktaMfaFactor {
public required string Id { get; set; }
public required string Provider { get; set; }
public required string VendorName { get; set; }

public const string UnsupportedMfaFactor = "unknown";

[JsonIgnore]
public virtual string FactorName => "unknown";
[JsonIgnore]
public virtual bool RequireChallengeIssue => false;
}

internal record OktaMfaQuestionFactor(
OktaMfaQuestionProfile Profile
) : OktaMfaFactor {
public const string FactorType = "question";
public override string FactorName => "Security Question";
}

internal record OktaMfaQuestionProfile(
string QuestionText
);

internal record OktaMfaTokenFactor() : OktaMfaFactor {
public const string FactorType = "token";
public override string FactorName => "Token";
}

internal record OktaMfaHardwareTokenFactor() : OktaMfaFactor {
public const string FactorType = "token:hardware";
public override string FactorName => "Hardware Token";
}

internal record OktaMfaSoftwareTotpFactor() : OktaMfaFactor {
public const string FactorType = "token:software:totp";
public override string FactorName => "Software TOTP";
}

internal record OktaMfaHotpFactor() : OktaMfaFactor {
public const string FactorType = "token:hotp";
public override string FactorName => "HOTP";
}

internal record OktaMfaSmsFactor() : OktaMfaFactor {
public const string FactorType = "sms";
public override string FactorName => "SMS";
public override bool RequireChallengeIssue => true;
}

internal record OktaMfaCallFactor() : OktaMfaFactor {
public const string FactorType = "call";
public override string FactorName => "Call";
public override bool RequireChallengeIssue => true;
}

internal record OktaMfaEmailFactor() : OktaMfaFactor {
public const string FactorType = "email";
public override string FactorName => "Email";
public override bool RequireChallengeIssue => true;
}

internal enum AuthenticationStatus {
UNKNOWN,
PASSWORD_WARN,
Expand Down
24 changes: 8 additions & 16 deletions src/D2L.Bmx/OktaAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,20 @@ bool ignoreCache
if( authnResponse is AuthenticateResponse.MfaRequired mfaInfo ) {
OktaMfaFactor mfaFactor = consolePrompter.SelectMfa( mfaInfo.Factors );

if( !IsMfaFactorTypeSupported( mfaFactor.FactorType ) ) {
if( mfaFactor.FactorName == OktaMfaFactor.UnsupportedMfaFactor ) {
throw new BmxException( "Selected MFA not supported by BMX" );
}

// TODO: Handle retry
if( mfaFactor.FactorType is "sms" or "call" or "email" ) {
if( mfaFactor.RequireChallengeIssue ) {
await oktaApi.IssueMfaChallengeAsync( mfaInfo.StateToken, mfaFactor.Id );
}
string mfaResponse = consolePrompter.GetMfaResponse( mfaFactor.FactorType == "question" ? "Answer" : "PassCode" );

string mfaResponse = consolePrompter.GetMfaResponse(
mfaFactor is OktaMfaQuestionFactor questionFactor ? questionFactor.Profile.QuestionText : "PassCode",
mfaFactor is OktaMfaQuestionFactor // Security question factor is a static value
);

authnResponse = await oktaApi.VerifyMfaChallengeResponseAsync( mfaInfo.StateToken, mfaFactor.Id, mfaResponse );
}

Expand Down Expand Up @@ -128,17 +133,4 @@ private List<OktaSessionCache> ReadOktaSessionCacheFile() {
var currTime = DateTimeOffset.Now;
return sourceCache.Where( session => session.ExpiresAt > currTime ).ToList();
}

private static bool IsMfaFactorTypeSupported( string mfaFactorType ) {
return mfaFactorType is
"call"
or "email"
or "question"
or "sms"
or "token:hardware"
or "token:hotp"
or "token:software:totp"
or "token";
}

}

0 comments on commit 78f403b

Please sign in to comment.