.NET 控制台通过屏蔽输入的字符来输入密码






4.40/5 (16投票s)
演示如何拦截 .NET 控制台的键盘输入,并在屏幕上显示 * 来替代每个实际输入的字符。
引言
我的一位同事曾问我,.NET 是否提供了任何控制台密码输入实用程序函数,以便应用程序能够接收和处理从键盘输入的字符,但不在屏幕上显示它们。取而代之的是,在输入的每个字符处输出一个 *
字符。
我不记得 .NET 中有任何此类实用程序,但我认为我可以用 C# 通过 DllImport
属性导入的 Windows 控制台 API 来开发一个。几天后,我成功创建了一个示例 .NET 控制台应用程序,其功能完全符合我同事的要求。
涉及技术总结
您需要使用 DllImportAttribute
从 Kernel32.DLL 导入几个控制台 API。
必须在 C# 中定义并使用与非托管 Kernel32 代码中使用的结构和联合匹配的结构和联合。StructLayoutAttribute
及其各种值(例如 LayoutKind
)在这里很重要。FieldOffsetAttribute
对于在 C# 中模拟联合也很关键。
IntPtr
struct
也很有用,它在 .NET 中定义,用于表示托管代码中的指针或句柄。
我还尝试了有效地使用委托来处理事件。
代码工作原理总结
我在 C# 代码中添加了大量注释,这些注释应作为文档。但是,代码的几个部分,包括我们拦截和重新显示键盘字符的顺序,需要更详细的解释。
这些将在下面总结
ConsolePasswordInput 类
键盘挂钩代码的主要逻辑封装在 ConsolePasswordInput
类中。此类使用通过 DllImport 导入的多个 Win32 控制台 API。
关键的控制台 API 是 ReadConsoleInput()
。下面将对此进行更详细的解释。ConsolePasswordInput
类的大部分成员数据和函数都基于此 API 的功能逻辑。
接下来,我将深入探讨 ReadConsoleInput()
API 及其相关的结构,然后再恢复讨论 ConsolePasswordInput
类。
ReadConsoleInput() API 和 INPUT_RECORD 结构
我们主要想拦截控制台键盘输入。我们通过使用 Win32 控制台 API ReadConsoleInput()
来实现这一目标。此方法会阻塞,直到发生控制台输入事件。控制台事件可以是键盘事件、鼠标事件、窗口缓冲区大小更改事件、菜单事件或控制台窗口焦点事件(有关更多详细信息,请参阅 MSDN 文档中的 ReadConsoleInput()
)。
在调用 ReadConsoleInput()
之前,我们必须定义一个 INPUT_RECORD
结构数组,该数组将在 ReadConsoleInput()
函数返回时被填充。
每个 INPUT_RECORD
结构都包含一个 KEY_EVENT_RECORD
、MOUSE_EVENT
、WINDOW_BUFFER_SIZE_EVENT
、MENU_EVENT
和 FOCUS_EVENT
结构的联合,每个结构都与可能发生的事件类型相关。
INPUT_RECORD
结构及其联合在 C# 中定义如下
// The EventUnion struct is also treated as a union in the unmanaged world.
// We therefore use the StructLayoutAttribute and the FieldOffsetAttribute.
[StructLayout(LayoutKind.Explicit)]
internal struct EventUnion
{
[FieldOffset(0)] internal KEY_EVENT_RECORD KeyEvent;
[FieldOffset(0)] internal MOUSE_EVENT_RECORD MouseEvent;
[FieldOffset(0)] internal WINDOW_BUFFER_SIZE_RECORD WindowBufferSizeEvent;
[FieldOffset(0)] internal MENU_EVENT_RECORD MenuEvent;
[FieldOffset(0)] internal FOCUS_EVENT_RECORD FocusEvent;
}
// The INPUT_RECORD structure is used within our application
// to capture console input data.
internal struct INPUT_RECORD
{
internal ushort EventType;
internal EventUnion Event;
}
INPUT_RECORD
结构定义为一个普通的 C# 结构。Event 联合的类型为 EventUnion
,它被定义为一个普通的 struct
,但指定了 "StructLayoutAttribute
" 和 "LayoutKind.Explicit
" 值,以指示 EventUnion
struct
的每个字段都用字节偏移量标记。
该字节偏移量由 "FieldOffsetAttribute
" 指定,它表示内存中 struct
的起始位置与字段的起始位置之间的字节数。
正如您在 EventUnion
struct
中看到的,字段 KeyEvent
、MouseEvent
、WindowBufferSizeEvent
、MenuEvent
和 FocusEvent
被标记为偏移量为 0。这是非托管 C/C++ 联合在 C# 中表示的唯一方式。
代码查找哈希表和 ConsoleInputEvent 委托
现在我们继续讨论 ConsolePasswordInput
类。该类定义了一个哈希表 (htCodeLookup
),用于将控制台事件类型映射到其事件处理程序。
我们定义了一个名为 ConsoleInputEvent
的委托,声明如下
// Declare a delegate to encapsulate a console event handler function.
// All event handler functions must return a boolean value indicating whether
// the password processing function should continue to read in another console
// input record (via ReadConsoleInput() API).
// Returning a true indicates continue.
// Returning a false indicates don't continue.
internal delegate bool
ConsoleInputEvent(INPUT_RECORD input_record, ref string strBuildup);
如上面代码的注释所述,该委托封装了一个控制台事件处理函数。每个控制台事件处理函数必须接受一个 INPUT_RECORD
结构和一个字符串的引用,最后返回一个布尔值。
事件处理函数的作用是处理控制台事件(无论其类型如何),并在过程中构建密码字符串。它将根据密码字符串是否被认为已完全构建返回 true 或 false 值(稍后将详细介绍)。
代码查找哈希表在 ConsolePasswordInput()
类的构造过程中初始化
// Public constructor.
// Here, we prepare our hashtable of console input event handler functions.
public ConsolePasswordInput()
{
htCodeLookup = new Hashtable();
// Note well that we must cast Constant.* event numbers to ushort's.
// This is because Constants.*_EVENT have been declared as of type int.
// We could have, of course, declare Constants.*_EVENT to be of type ushort
// but I deliberately declared them as ints to show the importance of
// types in C#.
htCodeLookup.Add((object)((ushort)(Constants.KEY_EVENT)),
new ConsoleInputEvent(KeyEventProc));
htCodeLookup.Add((object)((ushort)(Constants.MOUSE_EVENT)),
new ConsoleInputEvent(MouseEventProc));
htCodeLookup.Add((object)((ushort)(Constants.WINDOW_BUFFER_SIZE_EVENT)),
new ConsoleInputEvent(WindowBufferSizeEventProc));
htCodeLookup.Add((object)((ushort)(Constants.MENU_EVENT)),
new ConsoleInputEvent(MenuEventProc));
htCodeLookup.Add((object)((ushort)(Constants.FOCUS_EVENT)),
new ConsoleInputEvent(FocusEventProc));
}
请注意,我为所有五种类型的控制台事件都定义了处理程序,但只有 KeyEventProc()
函数不是微不足道的。这是因为我们只对在键盘输入过程中构建密码字符串感兴趣。当然,我们可以在其他事件期间执行密码处理(如果认为相关且有用)。
接下来,我们将检查 PasswordInput()
函数,并展示它如何使用各种事件处理程序来构建其密码字符串。
PasswordInput() 函数和 KeyEventProc() 函数
PasswordInput()
函数是 ConsolePasswordInput
类的主要公共函数。它首先初始化类的各种控制台句柄(hStdin
、hStdout
),如果它们尚未初始化,然后暂时设置控制台模式以启用鼠标和窗口控制台输入事件。
此函数中的 while
循环是键盘字符挂钩的主要驱动力
// Main loop to collect characters typed into the console.
while (bContinueLoop == true)
{
if
(
ReadConsoleInput
(
hStdin, // input buffer handle
irInBuf, // buffer to read into
128, // size of read buffer
out cNumRead // number of records read
) == true
)
{
// Dispatch the events to the appropriate handler.
for (uint i = 0; i < cNumRead; i++)
{
// Lookup the hashtable for the appropriate
// handler function... courtesy of Derek Kiong !
ConsoleInputEvent cie_handler =
(ConsoleInputEvent)htCodeLookup[(object)(irInBuf[i].EventType)];
// Note well that htCodeLookup may not have
// the handler for the current event,
// so check first for a null value in cie_handler.
if (cie_handler != null)
{
// Invoke the handler.
bContinueLoop = cie_handler(irInBuf[i], ref refPasswordToBuild);
}
}
}
}
这里,ReadConsoleInput()
函数在一个 while
循环中被调用。在每次循环中,都会调用 ReadConsoleInput()
函数。此函数会阻塞,直到发生控制台输入事件。
当发生此类控制台输入事件时,我们查找 htCodeLookup
哈希表,并确定与事件类型关联的 ConsoleInputEvent
委托。如果我们能找到一个委托,我们就调用它。
在我们的例子中,我们唯一感兴趣的委托是 KEY_EVENT
的委托。这就是为什么其他事件的委托会简单地返回 true
,指示 while
循环继续循环。
让我们检查 KeyEventProc()
函数
// Event handler to handle a keyboard event.
// We use this function to accumulate characters typed into the console and build
// up the password this way.
// All event handler functions must return a boolean value indicating whether
// the password processing function should continue to read in another console
// input record (via ReadConsoleInput() API).
// Returning a true indicates continue.
// Returning a false indicates don't continue.
private bool KeyEventProc(INPUT_RECORD input_record, ref string strBuildup)
{
// From the INPUT_RECORD, extract the KEY_EVENT_RECORD structure.
KEY_EVENT_RECORD ker = input_record.Event.KeyEvent;
// We process only during the keydown event.
if (ker.bKeyDown != 0)
{
// This is to simulate a NULL handle value.
IntPtr intptr = new IntPtr(0);
// Get the current character pressed.
char ch = (char)(ker.uchar.UnicodeChar);
uint dwNumberOfCharsWritten = 0;
// The character string that will be displayed
// on the console screen.
string strOutput = "*";
// If we have received a Carriage Return character, we exit.
if (ch == (char)'\r')
{
return false;
}
else
{
if (ch > 0)
// The typed in key must represent a character
// and must not be a control ley (e.g. SHIFT, ALT, CTRL, etc)
{
// A regular (non Carriage-Return character) is typed in...
// We first display a '*' on the screen...
WriteConsole
(
hStdout, // handle to screen buffer
strOutput, // write buffer
1, // number of characters to write
ref dwNumberOfCharsWritten, // number of characters written
intptr // reserved
);
// We build up our password string...
string strConcat = new string(ch, 1);
// by appending each typed in character at the end of strBuildup.
strBuildup += strConcat;
if (++iCounter < MaxNumberOfCharacters)
{
// Adding 1 to iCounter still makes iCounter
// less than MaxNumberOfCharacters.
// This means that the total number of characters
// collected so far (this is
// equal to iCounter, by the way)
// is less than MaxNumberOfCharacters.
// We can carry on.
return true;
}
else
{
// If, by adding 1 to iCounter makes iCounter
// greater than MaxNumberOfCharacters,
// it means that we have already collected
// MaxNumberOfCharacters number of characters
// inside strBuildup. We must exit now.
return false;
}
}
}
}
// The keydown state is false, we allow further characters to be typed in...
return true;
}
由于 KeyEventProc()
函数被 PasswordInput()
函数的 while
循环反复调用,因此它会累积输入到控制台的字符,并以此方式构建密码。
在处理 KEY_EVENT
时,还有两个重要条件需要注意:是按下了键(而不是释放了键),以及是否只有一个控制键(SHIFT、CTRL 或 ALT)被按下。
我们通过以下 "if
" 语句来处理第一个条件
if (ker.bKeyDown != 0)...
位于函数开头附近。KEY_EVENT_RECORD.bKeyDown
字段指示此情况。请注意,ReadConsoleInput()
函数在按下键时返回,在释放键时也会返回(即使释放的是与最初按下的键相同的键)。这是 ReadConsoleInput()
函数的一个预期规范,并不令人惊讶。为了防止重复处理同一个键,我们只在按下键时执行操作,并忽略释放键的情况。
我们通过以下 "if
" 语句来处理第二个条件
if (ch > 0) ...
就在 WriteConsole()
函数调用之前。这里 "ch
" 是 char
类型,它包含 KEY_EVENT_RECORD.uchar.UnicodeChar
的副本。
请注意另一个重要点:ReadConsoleInput()
函数在按下控制键时返回。如果按下的是非控制键,KEY_EVENT_RECORD.uchar.UnicodeChar
中的值将是所按字符的 Unicode 号码。
非控制键当然可以与控制键一起按下。如果是这样,KEY_EVENT_RECORD.uchar.UnicodeChar
将包含一个有效的 Unicode 字符,并且 KEY_EVENT_RECORD.dwControlKeyState
字段将包含适当的值,指示控制键的适当状态(有关更多详细信息,请参阅 MSDN 文档中的 KEY_EVENT_RECORD
struct
)。
这一点很重要,因为它确保我们能够在密码中输入大写和小写字符。
但是,如果只按下一个控制键,KEY_EVENT_RECORD.uchar.UnicodeChar
将为零。这就是为什么我们检查 "ch
" 是否非零。
由于键盘输入被 ReadConsoleInput()
函数拦截,因此默认情况下控制台不会显示输入的字符。我们的 KeyEventProc()
函数利用此机会通过 WriteConsole()
函数向控制台屏幕显示一个 '*
' 字符。
当条件合适且密码被认为完全构建时(这将是按下回车键时,或者达到允许的最大密码字符数时),此函数将返回 false
,以指示 PasswordInput()
函数停止其 while
循环。
Main() 函数
ConsolePasswordInput
类中包含一个 Main()
函数。此函数用作如何使用 ConsolePasswordInput
类的示例。
我们首先实例化此类的一个对象
ConsolePasswordInput cpi = new ConsolePasswordInput();
然后我们进入一个 while
循环,在其中反复调用 ConsolePasswordInput
对象的 PasswordInput()
函数来从用户那里获取密码
// Get an instance of the ConsolePasswordInput class to retrieve a password
// of maximum size 5 characters.
while (iTries < 3)
{
iTries++;
strPassword = "";
System.Console.Write ("Please enter your password : ");
cpi.PasswordInput(ref strPassword, 20);
System.Console.WriteLine();
System.Console.WriteLine("Typed in password : {0}", strPassword);
if (strPassword == "CodeProject")
{
System.Console.WriteLine ("Correct !");
break;
}
else
{
if (iTries < 3)
{
System.Console.WriteLine ("try again...");
}
else
{
System.Console.WriteLine ("Wasted...");
}
}
}
我们这样做,直到用户尝试最多三次,或者密码正确(我们这里非常简单的示例使用 "CodeProject" 作为密码字符串)。
请注意,我们可以使用普通的 System.Console
行输出函数(Write()
和 WriteLine()
)。我们 ConsolePasswordInput
类中的键盘挂钩函数不会干扰这些。
结论
本文的源代码包含完整的 Visual Studio .NET 解决方案项目文件。默认情况下它是一个应用程序,但可以轻松地更改为库。
除了展示如何拦截控制台键盘输入外,我还尝试展示如何在 C# 中构造联合。我还尝试展示委托和哈希表的有效使用。
我当然希望本文能对其他开发者有所帮助。
此致,
Bio。