diff --git a/src/D2L.Bmx/ConsolePrompter.cs b/src/D2L.Bmx/ConsolePrompter.cs index 019b1862..8412148e 100644 --- a/src/D2L.Bmx/ConsolePrompter.cs +++ b/src/D2L.Bmx/ConsolePrompter.cs @@ -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 { @@ -64,73 +64,9 @@ string IConsolePrompter.PromptUser( bool allowEmptyInput ) { } string IConsolePrompter.PromptPassword( string user, string org ) { - Func 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() { @@ -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 ) { @@ -208,9 +144,15 @@ 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; @@ -218,6 +160,74 @@ string IConsolePrompter.GetMfaResponse( string mfaInputPrompt ) { throw new BmxException( "Invalid MFA Input" ); } + private string GetMaskedInput( string prompt ) { + Func 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" ); diff --git a/src/D2L.Bmx/D2L.Bmx.csproj b/src/D2L.Bmx/D2L.Bmx.csproj index 42556208..24bda7a8 100644 --- a/src/D2L.Bmx/D2L.Bmx.csproj +++ b/src/D2L.Bmx/D2L.Bmx.csproj @@ -15,6 +15,7 @@ + diff --git a/src/D2L.Bmx/JsonSerializerContext.cs b/src/D2L.Bmx/JsonSerializerContext.cs index e123b104..4466f472 100644 --- a/src/D2L.Bmx/JsonSerializerContext.cs +++ b/src/D2L.Bmx/JsonSerializerContext.cs @@ -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 ) )] diff --git a/src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs b/src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs index d7509056..77812f22 100644 --- a/src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs +++ b/src/D2L.Bmx/Okta/Models/AuthenticateResponse.cs @@ -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, diff --git a/src/D2L.Bmx/OktaAuthenticator.cs b/src/D2L.Bmx/OktaAuthenticator.cs index 0abd199c..d459266d 100644 --- a/src/D2L.Bmx/OktaAuthenticator.cs +++ b/src/D2L.Bmx/OktaAuthenticator.cs @@ -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 ); } @@ -128,17 +133,4 @@ private List 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"; - } - }