Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve question MFA prompt #458

Merged
merged 4 commits into from
Jul 11, 2024
Merged
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
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 ) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Refactored this masked input read to be re-usable (for GetMfaResponse)

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 ======
Copy link
Member Author

Choose a reason for hiding this comment

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

It is going to pop this twice once for password + security question, in this case. But meh, that is a very niche edge case

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";
}

}
Loading