简介:用C#编写的登录器完整源码,面向C#语言初学者与桌面应用开发爱好者,适合作为练习项目对照学习。资源围绕登录器的完整实现展开,覆盖界面布局、账号输入校验、身份信息核对、登录失败反馈和皮肤样式切换等功能,也体现了类、事件、控件交互等面向对象基础的使用,能帮助读者把语言语法与桌面编程实践结合起来。资源包共六十六个文件,容量仅一百二十四KB,核心内容以源代码文件和界面资源文件为主,另含解决方案与工程配置、可执行程序、动态库等辅助材料,目录结构紧凑清晰,可在开发环境中直接打开查看运行。已有四百三十七人学习下载,适合希望通过完整实例快速掌握桌面应用开发的开发者。深入阅读源码可以看到,登录器被拆分为多个窗体与工具类,分别负责用户数据读取、本地配置管理和外观资源替换;对照各窗体中的事件绑定、输入验证、错误捕获、文件读写等写法,可以学到控件布局、界面视觉调整、异常处理以及资源引用等多方面实用技巧;另需留意,压缩包中还包含部分额外工程备份文件,使用前应先自行审查代码与用途。
1. 登录器C#源码:别把“输入密码跳主界面”当成登录器的全部
我拆过一份某项目的登录器C#源码,表面看就是个 WinForms 小窗口:文本框、登录按钮、退出按钮。等看到三分之一处才意识到,这套源码真正花心思的全在“登录成功之后”——记住密码的存储、自动登录的令牌续期、数据库的路径解析、多开互斥、以及不小心就会翻车的 WinForms 消息循环切换。对想自己做桌面登录器、又不想在认证细节上反复踩坑的开发者来说,这套东西是能直接落地的参考。文章里我会把拆解过程中的选型逻辑、核心代码和血泪排查记录都摊开说,照着敲一遍就能跑通。
2. 登录器的骨架:先把认证流程和数据层夯实
2.1 登录器认证流程:本地校验与服务端校验怎么选
登录器是桌面客户端,职责是“收集凭证、校验身份、交接会话”。很多刚上手的人以为登录器就是把文本框里的字符串和数据库里的密码字段比一下,逻辑上没错,但这只是最简单的一层。真正要定的是身份校验发生在哪一端:本机还是服务端。
这套源码默认做的是本机校验。它把用户数据放在一个随程序分发的 SQLite 文件里,登录器启动后直接读本地库完成密码比对。这种做法的好处是部署极轻:不用装数据库服务,不用配网络,复制一个 exe 加一个 dll 目录就能跑,非常适合单机工具、离线环境和小规模内部系统。代价也很明显:数据库文件一旦被有本机权限的人拿走,所有账号信息都在本地,如果密码存储做得不认真,等于把用户凭证直接送给对方。
反过来,如果登录器要服务的场景是多台电脑统一账号,本地校验就不合适了,正确方向是服务端校验。客户端把用户名和密码通过 HTTP 或自定义协议送到服务端,由服务端查库、校验、签发会话令牌,客户端只保存令牌。源码里虽然默认走本地库,但在数据访问层留了 SQL Server 接口,这说明它在设计之初就考虑过“某天要切到服务端”的可能。这个思路值得学习:登录器的数据访问不要和界面代码揉在一起,否则以后想从本地校验换到服务端校验,要改的地方会多到让人想重写。
2.2 数据层选型:SQLite 与 SQL Server 的分界线
在数据访问层我看到了两个实现,一个接 SQLite,一个接 SQL Server,默认启用的是 SQLite。两个选型之间并没有谁绝对更好的说法,关键看登录器的部署环境和账号管理方式,我习惯用下面这个表来判断:
| 维度 | SQLite | SQL Server |
|---|---|---|
| 部署方式 | 文件型数据库,程序目录一个 .db 文件 | 独立数据库服务,或随装的 LocalDB |
| 适用场景 | 单机、离线、小工具、原型验证 | 多客户端、集中账号管理、权限审计 |
| 连接串示例 | Data Source=app.db | Server=.\LocalDB;Database=Login;Integrated Security=true |
| 备份迁移 | 拷贝文件即完成备份 | 需要数据库备份/分离附加 |
| 并发写入 | 弱,多进程同时写可能锁库 | 强,天然支持多客户端并发 |
| 账号权限体系 | 无,靠文件系统权限 | 有完整的用户与角色模型 |
如果登录器只是给一台电脑用,选 SQLite 是明智的。它不需要安装额外服务,发布时甚至连数据库文件都不用预置,首次运行自动建表就行。但换成多客户端共用一个账号库时,SQLite 的短板就暴露了:两个进程同时写同一个 .db 文件,轻则报 database is locked,重则直接把库写坏。常见做法是直接切到 SQL Server,或者让程序只做单向读,写入统一走服务端接口。这套源码留着 SQL Server 通道而不在默认路径里启用,既保住了单机场景的简单性,又给后续扩展留了退路,比一开始就上重型数据库要务实得多。
还有一个细节:SQLite 没有存储过程,没有服务端逻辑,校验规则全在客户端。这意味着登录器程序的二进制一旦被反编译,攻击者可以完全看清校验逻辑并在内存里绕过。所以本地校验方案只适合信任边界内的场景,不适合把登录器做成面向公众的账号入口。
2.3 用户表设计:一份能用三年的 User 表
这套源码 users 表的结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键,自增 |
| username | TEXT NOT NULL UNIQUE | 用户名,唯一约束 |
| password_hash | TEXT NOT NULL | 密码哈希值,非明文 |
| salt | TEXT NOT NULL | 哈希盐,单独一列 |
| is_active | INTEGER NOT NULL DEFAULT 1 | 状态位,0 表示禁用账号 |
| last_login_at | TEXT NULL | 最近登录时间,存 ISO 字符串 |
| login_count | INTEGER NOT NULL DEFAULT 0 | 累计登录次数 |
| created_at | TEXT NOT NULL DEFAULT (datetime('now','localtime')) | 注册时间 |
这张表让我印象最深的是 salt 单独一列存,而不是和哈希拼在一起。很多网上教程喜欢把哈希和盐拼成一个字符串,程序里再按固定长度截取。那样看着省事,但一旦以后想改算法、改盐长度,老数据全部作废,程序只能让所有用户重新注册。分开存的好处是迁移时可以把老哈希原样读出,逐个重算,不会一夜之间搞丢全部账号。这正是我常说的“后悔药”:编码时多留一列,升级时少熬一夜。
last_login_at 用 TEXT 而不是 DATETIME,也是这套源码的一个经验点。SQLite 内部没有原生日期类型,datetime 函数返回的就是字符串,直接用 TEXT 存储反而省心。登录器里所有时间比较都走 ISO 8601 字符串,排序、比较都能正常工作。要特别注意时区问题:如果有的字段用本地时间,有的用 UTC,后面查“谁在什么时间登录过”会非常难受,所以最好在写入层统一转换一次,我一般全存 UTC,只有显示给用户时才转本地。
3. 核心登录模块:从建库到校验的完整落地
3.1 初始化数据库:首次运行自动建表
登录器首次启动时要做的事情比想象中多:确认数据库目录存在、创建 db 文件、建表。源码里的初始化类代码如下:
using Microsoft.Data.Sqlite; public class DatabaseInitializer { private readonly string _connectionString; public DatabaseInitializer(string dbPath) { _connectionString = $"Data Source={dbPath}"; } public void Initialize() { using var connection = new SqliteConnection(_connectionString); connection.Open(); var createTableSql = @" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, salt TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, last_login_at TEXT NULL, login_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) );"; using var command = new SqliteCommand(createTableSql, connection); command.ExecuteNonQuery(); } }这段逻辑不复杂,但有两个细节值得说。第一个是 CREATE TABLE IF NOT EXISTS,让创建语句天然幂等,登录器每次启动都可以放心调用 Initialize,不用额外判断表是否已经存在。第二个是连接串用 Data Source 指定文件路径,dbPath 这个入参必须是一个绝对路径,否则后面会出现“换目录运行就连不上库”的经典问题,具体排查记录放到第4章。
参数方面,connectionString 里的 Data Source 对应 SQLite 数据库文件位置;AUTOINCREMENT 让 id 单调递增,避免主键复用;UNIQUE 约束保证用户名不重复。如果你希望数据库落在内存里做纯测试,也可以把 Data Source 换成 :memory:,但登录器这种场景基本用不到,别在生产代码里轻易用它。
3.2 密码加盐哈希:PBKDF2 比 MD5 靠谱多少
源码早期版本用的是 MD5 加盐,拆的时候我很想叹气,好在后来改成 PBKDF2 了。直接说结论:登录器密码存储不要用裸 MD5,也不要只加固定盐再 MD5,正确姿势是用 PBKDF2、bcrypt 或 Argon2 这类带迭代次数的慢哈希算法。下面是源码里改完之后的写法:
using System.Security.Cryptography; public static class PasswordHasher { private const int Iterations = 10000; private const int SaltSize = 16; private const int HashSize = 32; public static (string hash, string salt) HashPassword(string password) { byte[] saltBytes = RandomNumberGenerator.GetBytes(SaltSize); using var deriveBytes = new Rfc2898DeriveBytes( password, saltBytes, Iterations, HashAlgorithmName.SHA256); byte[] hashBytes = deriveBytes.GetBytes(HashSize); string salt = Convert.ToBase64String(saltBytes); string hash = Convert.ToBase64String(hashBytes); return (hash, salt); } public static bool VerifyPassword(string password, string storedHash, string storedSalt) { byte[] saltBytes = Convert.FromBase64String(storedSalt); using var deriveBytes = new Rfc2898DeriveBytes( password, saltBytes, Iterations, HashAlgorithmName.SHA256); byte[] hashBytes = deriveBytes.GetBytes(HashSize); string computed = Convert.ToBase64String(hashBytes); return computed == storedHash; } }逻辑说明:HashPassword 首先生成 16 字节随机盐,然后让密码和盐一起经过 10000 次 PBKDF2 迭代,得到 32 字节哈希,最后把盐和哈希各自 Base64 编码后存库。VerifyPassword 做的是同样迭代计算,把计算结果和库里的哈希做字符串比较。注意比较时要避免用不恒定的 Equals,防止时间侧信道,这里为了代码简洁用了 ==,实际项目中最好用 CryptographicOperations.FixedTimeEquals,不过桌面登录器的风险等级通常不需要卷到这一步。
参数说明:Iterations=10000 是当前比较稳妥的起点,机器性能好的可以调成 30000 或更高;SaltSize=16 字节对应 128 位,足够防止彩虹表;HashSize=32 字节对应 SHA-256 输出长度。如果你把源码跑在 .NET Framework 4.7.2 上,RandomNumberGenerator.GetBytes 和带 HashAlgorithmName 的 Rfc2898DeriveBytes 构造函数可能不可用,需要退回 RNGCryptoServiceProvider 和旧构造函数。这个兼容性坑我一开始没注意,结果在项目现场的机器上直接编译失败,最后老老实实做了目标框架分支。
3.3 登录校验与错误提示:返回码要设计成能排查的
登录校验逻辑在源码里收敛得比较好,没有把 SQL 查询散落在按钮点击事件里。它先用一个枚举定义登录结果,再在数据访问层返回这个枚举:
public enum LoginResult { Success = 0, UserNotFound = 1, WrongPassword = 2, UserDisabled = 3 } public LoginResult Login(string username, string password) { using var connection = new SqliteConnection(_connectionString); connection.Open(); const string sql = @" SELECT password_hash, salt, is_active FROM users WHERE username = @username;"; using var command = new SqliteCommand(sql, connection); command.Parameters.AddWithValue("@username", username); using var reader = command.ExecuteReader(); if (!reader.Read()) { return LoginResult.UserNotFound; } if (reader.GetInt32(2) == 0) { return LoginResult.UserDisabled; } string storedHash = reader.GetString(0); string salt = reader.GetString(1); bool ok = PasswordHasher.VerifyPassword(password, storedHash, salt); return ok ? LoginResult.Success : LoginResult.WrongPassword; }SQL 里的 @username 是参数化占位符,由 AddWithValue 赋值,这比把用户输入直接拼进 SQL 字符串安全得多。所有涉及用户输入的查询都应该走参数化,这是登录器代码里最基本也最不能妥协的一条红线。
把登录结果设计成枚举,而不是直接在界面层弹 MessageBox,好处是调用方可以根据返回码做不同处理。比如 UserNotFound 和 WrongPassword,前端提示用户时统一显示“用户名或密码错误”,避免攻击者通过提示差异枚举有效账号;但日志里却要记录具体原因,方便开发者排查。这就是“对用户收敛、对调试展开”的典型做法。如果登录时出现 UserDisabled,可以额外提示账号被禁用,这是产品层面的明确信息,不算泄露。
提示:SQL 参数化是登录器代码里最基础的防线,但很多改过来的项目会在“忘记参数化”和“过度参数化”之间摇摆。记住一个原则:凡是来自用户输入、配置文件或外部接口的字符串,进 SQL 之前一律当参数处理。
3.4 记住密码与自动登录的实现边界
这套源码里“记住密码”和“自动登录”是两个分开的功能,这一点做得很好。记住密码只保存用户名和一个随机令牌,不保存密码本身;自动登录则用令牌换会话。如果谁图省事把密码明文写进配置文件,那登录器就成密码保险柜的钥匙了。这边的实现思路是:
- 用户勾选“记住我”并登录成功后,生成一个随机 token。
- 把 token 写入数据库的 remember_tokens 表,同时写入本地受保护文件。
- 下次程序启动,先尝试用本地 token 换登录态,失败就退回登录窗口。
核心代码如下:
public static void SaveRememberToken(string username, string token) { string appData = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData); string dir = Path.Combine(appData, "MyLoginTool"); Directory.CreateDirectory(dir); string fileName = Path.Combine(dir, "remember.dat"); byte[] plain = Encoding.UTF8.GetBytes($"{username}\n{token}"); byte[] encrypted = ProtectedData.Protect( plain, null, DataProtectionScope.CurrentUser); File.WriteAllBytes(fileName, encrypted); }这段代码用了 Windows DPAPI,也就是 ProtectedData.Protect 做当前用户级加密。加密后的文件即使被复制到另一台电脑,在别的用户上下文里也解不开。DataProtectionScope.CurrentUser 表示加密与当前 Windows 用户账号绑定,换成 LocalMachine 则任何本机用户都能解密,登录器场景下千万别用 LocalMachine。
自动登录的读取端要留意 token 过期:数据库里给 token 加一个 expires_at 字段,读取时先查是否过期,过期直接删除并删本地文件,让用户重新登录。很多自动登录翻车都发生在过期判断上,比如拿本地时间跟 UTC 时间比,差几个小时甚至一天,用户会发现“明明勾了记住我,每次还是要重新登录”。这个坑在第4章里会详细写。
4. 登录器避坑:五个排查记录,每条都是真金白银
4.1 现象:换一台电脑登录器就连不上库?
现象:本地开发一切正常,把整个程序目录拷到另一台电脑,双击 exe 后点击登录直接报“unable to open database file”。
原因:开发时连接串写的是相对路径 "Data Source=login.db"。相对路径是相对于进程的当前工作目录(Environment.CurrentDirectory)解析的,双击快捷方式、被其他程序拉起、或者以服务方式运行时,当前工作目录并不一定是 exe 所在目录,SQLite 找不到 login.db,直接抛异常。
解决:把 dbPath 改成基于 AppDomain.CurrentDomain.BaseDirectory 的绝对路径。这个属性返回 exe 所在目录,发布时在哪跑都在哪。
string dbPath = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "login.db");从那以后我再没让登录器的数据库路径用相对路径。注意 AppDomain.CurrentDomain.BaseDirectory 末尾可能带反斜杠,用 Path.Combine 能正确处理连接符,不用手拼字符串。
4.2 现象:密码正确却提示登录失败?
现象:用户能注册成功,也能登录成功一次,但第二次登录时明明密码没变,程序却提示“用户名或密码错误”。
原因:这种问题八成出在输入处理和哈希入口不一致上。比较常见的三个来源:注册时对用户名做了 Trim(去掉首尾空格),登录时却忘了;从配置文件读取的密码带着前后空格;更隐蔽的是哈希算法不一致,比如注册用的旧函数内部是 MD5 加盐,升级程序后登录逻辑变成了 PBKDF2,两边的算法对不上,盐和哈希长度也对不上。
解决:把 Trim 统一收敛在文本框的 Validating 事件里,密码哈希的生成和校验统一走同一个 PasswordHasher 类,不要让注册和登录两处各自写哈希逻辑。排查时先在 PasswordHasher.VerifyPassword 入口打日志,把传入的 password、storedHash、salt 长度打出来,对比注册时的记录,基本一眼能定位。
4.3 现象:杀毒软件把登录器当木马删了?
现象:程序刚发布,杀毒软件就弹窗报毒,甚至直接隔离 exe,用户还没打开登录器就先收到系统警告。
原因:C# 编译的托管程序本身不太容易被误报,但登录器这类工具踩雷通常是三种情况:一是代码里有写入注册表 Run 启动项、注入别的进程、破坏性文件操作等行为特征,被启发式引擎盯上;二是用了老版本 .NET Framework 编译,特征库命中已知恶意样本特征;三是程序集没有强名称签名,也没做 Authenticode 数字签名,系统把未知来源程序按默认风险处理。
解决:先检查代码里有没有不该出现的敏感 API 调用,把写启动项这类行为去掉;然后给项目启用强名称签名,最好再用代码签名证书对 exe 做 Authenticode 签名。发布安装包时用标准安装项目或 MSIX 打包,而不是直接把散文件丢给用户。如果还是误报,就提交到杀毒软件厂商的误报申诉通道,等特征库更新。玄学点说,这个问题的解决周期基本由签名和申诉决定,代码本身往往没什么问题。
4.4 现象:自动登录失效又不报错,卡在半路?
现象:用户勾选“记住我”并登录成功,重启程序后没有再显示登录窗口,但也进不了主界面,程序就那么无声无息地卡着,后台日志一点异常都没有。
原因:自动登录的读取逻辑里对比了 token 过期时间,但写入时用的是本地时间,读取时用的是 DateTime.UtcNow,两者相差 8 小时。部分 token 刚好在“过期前 2 小时”这个窗口里被误判过期,于是自动登录静默失败;失败后的逻辑只写了 return,没有弹窗也没有日志,给人的感觉就像程序坏了。
解决:统一使用 DateTime.UtcNow 写入和校验 expires_at,只在显示给用户时转换成本地时间。另外自动登录失败路径一定要留下日志或者至少推动态提示,不能裸 return。血的教训:任何“静默失败”都会变成用户眼里的玄学 bug,因为他们完全看不到发生了什么。
4.5 现象:点登录后主窗体闪一下就没影了?
现象:登录窗口输入账号密码,按钮事件里执行校验成功后 new 了一个 MainForm 并 Show,结果主窗体一闪而过,程序直接退出。
原因:这是 WinForms 初学者最容易踩的消息循环坑。如果 Program.cs 里写的是 Application.Run(new LoginForm()),登录窗体关闭后,Application.Run 的消息循环结束,整个进程就退出了。登录窗口里再 Show 出来的 MainForm 因为没有消息循环支撑,只画了一帧就被进程退出带走。
解决:把入口改成 ApplicationContext,在登录成功后替换主窗体。
[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new AppContext()); } public class AppContext : ApplicationContext { private readonly LoginForm _loginForm; public AppContext() { _loginForm = new LoginForm(); _loginForm.LoginSucceeded += OnLoginSucceeded; _loginForm.Show(); } private void OnLoginSucceeded(object? sender, LoginEventArgs e) { _loginForm.Hide(); var mainForm = new MainForm(e.SessionToken) { StartPosition = FormStartPosition.CenterScreen }; mainForm.FormClosed += (_, _) => ExitThread(); mainForm.Show(); } }这套做法里,LoginForm 不再业务代码里 new MainForm,而是通过事件把登录结果传出去。AppContext 接收事件后切换窗体,并通过 ExitThread 在 MainForm 关闭时结束进程。这样登录窗口和主窗体各自生命周期清晰,不会再生出“闪一下就没”的怪问题。
5. 再进一步:把登录器做成别人愿意维护的样子
5.1 单实例运行:防止多个登录器互抢数据库
SQLite 对多进程并发写入支持很差,两个登录器同时打开同一个 db 文件,后写的一方迟早遇到锁错误。最简单可靠的方案是进程级互斥体,用 Mutex 保证同一时间只有一个登录器实例在跑:
static Mutex? _mutex; string mutexName = "Global\\MyLoginTool_SingleInstance"; _mutex = new Mutex(true, mutexName, out bool createdNew); if (!createdNew) { MessageBox.Show("登录器已经在运行中。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Information); return; }注意 mutexName 里的 Global\ 前缀让互斥体在终端服务会话之间也生效,适合远程桌面场景。如果没有这个前缀,同一台机器不同用户会话可能各开一个实例,照样把库写坏。这段代码放在 Program.Main 最前面,申请成功才进入消息循环。
5.2 登录态的传递:从 LoginForm 到 MainForm 的三条路
登录成功后怎么把用户信息交给主窗体,是登录器代码里一个绕不开的小设计。常见做法有三种:静态类、事件参数、数据库记录。
静态类最直接:
public static class UserSession { public static string Username { get; set; } = string.Empty; public static string Token { get; set; } = string.Empty; }事件参数方式在上面 AppContext 代码里已经演示了,优点是不引入全局状态,但要求窗体事件接线清晰。数据库记录方式适合服务端登录器,登录成功后把会话写进表,主窗体再查一次;缺点是增加一次 IO 和出错点。我一般更偏向事件参数 + 静态缓存组合:登录事件里把数据放进 UserSession,MainForm 读取,逻辑直白,调试时也容易打断点观察。
5.3 一份“下次不翻车”的部署验证清单
每次登录器改完发布前,我都会强制自己过一遍下面的清单,虽然看起来繁琐,但能省掉大量线上沟通成本:
| 检查项 | 验证方法 | 预期结果 |
|---|---|---|
| 数据库路径 | 删除 exe 目录下的 db,再启动登录器 | 自动重建库和表 |
| 相对路径 | 用命令行从别的目录启动 exe | 能正常连库 |
| 密码哈希 | 注册一个包含空格和中文的密码 | 登录时 Trim 后校验通过 |
| 记住密码 | 勾选记住并重启程序 | 跳过登录窗口,进入主窗体 |
| 自动登录过期 | 修改 token 过期时间为过去时 | 退回登录窗口,无卡死 |
| 杀毒扫描 | 用常见杀毒引擎全盘扫描 exe | 无高危报毒 |
| 多次启动 | 双击两次 exe | 第二次提示实例已存在 |
这套清单是从我拆过的那个登录器源码和实际项目里总结出来的,也算是血泪经验。以前我发布前只跑一条“能登录就 OK”,结果经常是用户一装就出幺蛾子。从那以后,我每次登录器改完都强制把这七项全走一遍,再忙也不跳过数据库路径和自动登录过期这两项,因为它们出问题时的表现最像“随机 bug”,最消耗排查时间。希望这份清单和文章里的代码能帮到你少走几步弯路。
本文还有配套的精品资源,点击获取