ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Qt程序添加登录界面:账号存储、密码加密与发布实战

Qt程序添加登录界面:账号存储、密码加密与发布实战 去年年底我给团队内部一个已经跑了大半年、原本谁拿到就能用的Qt工具程序加了一套登录界面。听起来就是加个窗口、校验下账号密码真正动手才发现牵扯到的东西比预想的多账号存哪里、密码怎么存才不至于裸奔、登录失败要不要锁、主程序怎么切换窗口、发布的时候数据库驱动为什么经常丢……这一套理顺之后我把整个过程整理成了一份可以照着做的方案。如果你手上也有一个已经能跑的Qt程序想从“打开就用”升级成“有登录、有身份、有权限”的正式工具这篇内容基本能覆盖从方案选型到打包发布的核心路径适合已经能写出QMainWindow、想往工程化方向走一步的Qt开发者。1. 先想清楚登录界面到底在解决什么问题1.1 不是每个程序都需要加登录动手写代码之前我的建议是先反问自己三个问题程序是给人随便用的内部工具还是需要分清谁是谁数据是个人本地的还是多人共享的如果登录逻辑被绕过后果有多严重很多情况下一台机器上只有两三个固定用户其实不需要完整账号体系做一个简单的“启动口令”或者“密码门”就够了。但如果你要解决的是“谁动了数据能查出来”“不同角色看到的功能不一样”“远程维护时不能随便放人进去”那就得认真设计登录模块。我这次做的是内部工具主打轻量、离线、单人单机所以采用了“本地SQLite数据库 加盐哈希校验”的方案没有引入服务端。如果你的程序需要多人在线、账号统一管理思路一样只是把校验函数从查本地库换成调用后端接口交互流程完全可复用。1.2 账号信息存哪本地文件、SQLite还是服务器这是做登录界面第一个绕不开的技术选型。我见过有人把账号密码写死在代码里也见过用QSettings存一个配置项里面塞用户密码这两种方案只适合演示不适合正经工具。我把常见方案拉了一张对比表存储方案实现成本安全性适用场景代码写死最低极低反编译即泄露个人学习演示配置文件/QSettings低低明文存储风险高临时工具、单人内网本地SQLite中较高配合哈希存储单机/局域网小团队远端数据库/后端API高高可统一管控多用户、跨设备、需审计我最后选了SQLite。原因很实际Qt自带QSQLITE驱动不需要额外装数据库服务接口统一后续如果要把users表换成MySQL改动成本很低而且SQLite本身支持并发读团队几个人同时使用也没问题。注意一点本地存储不等于安全密码绝对不能明文存后面会专门讲哈希处理。1.3 登录流程的三种常见模式登录界面和主程序的衔接方式我见过三种做法方案A程序启动先弹一个模态登录框密码对了才显示主窗口。这是最正统的桌面软件做法逻辑简单登录框和主窗口的生命周期边界清晰推荐大多数项目使用。方案B主窗口先显示但未登录时所有功能按钮置灰只有登录后才解锁。这种做法适合“主界面本身有展示价值、不希望用户打开软件就面对一个白屏框”的场景但容易出现“绕过UI直接调功能”的漏洞代码里还要到处判断登录状态。方案C登录界面本身就是主窗口登录成功后切换中央组件。这种做法有点偏前端网页风格动画可以做得好看但Qt里频繁切换中央组件容易把状态管理搞乱不推荐新手尝试。我这篇文章按方案A展开。它的核心就是登录框用QDialog通过exec()模态执行校验通过后accept()主程序拿到用户名再进主窗口。2. 登录界面的UI搭建与交互细节2.1 用Qt Creator快速建一个LoginDialog如果你用的是Qt Creator最简单的做法是右键项目选择“添加新文件”在“Qt”分类下选择“Qt设计师界面类”基类选QDialog会自动生成loginDialog.ui和对应的LoginDialog类。我习惯同时也勾选“创建界面”因为.ui文件用设计器摆放控件很方便后期调整布局不用改代码。如果不喜欢.ui纯代码写UI也很清晰。下面是LoginDialog的头文件骨架class LoginDialog : public QDialog { Q_OBJECT public: explicit LoginDialog(QWidget *parent nullptr); private slots: void onLoginClicked(); void onCancelClicked(); private: QLineEdit *m_userEdit; QLineEdit *m_pwdEdit; QCheckBox *m_rememberCheck; QPushButton *m_loginBtn; QPushButton *m_cancelBtn; QLabel *m_tipsLabel; };构造函数里放一个垂直布局从上到下依次是标题、用户名输入框、密码输入框、记住密码勾选框、错误提示标签、按钮行。最基本的样子LoginDialog::LoginDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(QStringLiteral(登录)); setFixedSize(360, 260); m_userEdit new QLineEdit(this); m_userEdit-setPlaceholderText(QStringLiteral(请输入用户名)); m_pwdEdit new QLineEdit(this); m_pwdEdit-setPlaceholderText(QStringLiteral(请输入密码)); m_pwdEdit-setEchoMode(QLineEdit::Password); m_loginBtn new QPushButton(QStringLiteral(登 录), this); m_cancelBtn new QPushButton(QStringLiteral(取 消), this); auto *formLayout new QFormLayout; formLayout-addRow(QStringLiteral(用户名:), m_userEdit); formLayout-addRow(QStringLiteral(密 码:), m_pwdEdit); auto *btnLayout new QHBoxLayout; btnLayout-addStretch(); btnLayout-addWidget(m_loginBtn); btnLayout-addWidget(m_cancelBtn); btnLayout-addStretch(); auto *mainLayout new QVBoxLayout(this); mainLayout-addLayout(formLayout); mainLayout-addWidget(m_rememberCheck); mainLayout-addWidget(m_tipsLabel); mainLayout-addLayout(btnLayout); }这个窗口麻雀虽小五脏俱全你能看到用户名、密码、按钮、提示标签都有了。后面所有交互逻辑都挂在这几个控件上。2.2 回车登录、密码可见性、焦点这些细节别偷懒登录窗口最容易让用户骂街的就是按回车没反应。很多人写登录界面只顾着用鼠标点“登录”按钮忘了键盘用户的存在。解决方式很简单connect(m_pwdEdit, QLineEdit::returnPressed, this, LoginDialog::onLoginClicked);这样在密码框里敲回车会直接执行登录校验。另外如果你希望窗口一打开用户就能直接输密码可以设置初始焦点m_userEdit-setFocus();密码输入框默认是掩码状态但经常有人输错一长串密码看不到内容。我在项目里给密码框右侧加了一个小眼睛按钮点击可以切换EchoMode在Password和Normal之间切换。这个交互很多大厂登录页都有Qt里实现也就几行代码体验提升很明显。还有一个容易被忽略的点错误提示不要放在QMessageBox里弹窗否则密码输错了要被一个模态框拦住用户体验很割裂。我直接在界面上放一个QLabel校验失败时把它设成红色文字显示“用户名或密码错误”。用户连输几次错误提示还可以带上剩余尝试次数。2.3 用QSS让登录界面摆脱“程序默认脸”Qt自带的原生控件长得确实不好看但登录界面又是一个看脸的地方。我的经验是不要一上来就追求复杂的自绘控件先用QSS把默认控件的边距、圆角、配色改一遍观感能提升一大截。#loginDialog { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #f5f7fa, stop:1 #c3cfe2); } QLineEdit { border: 1px solid #d0d0d0; border-radius: 6px; padding: 6px 10px; background: white; font-size: 14px; } QLineEdit:focus { border-color: #4a90d9; } QPushButton { border-radius: 6px; padding: 8px 20px; background: #4a90d9; color: white; font-size: 14px; } QPushButton:hover { background: #3a7bc8; } QPushButton:pressed { background: #2f6bb0; }把这段内容放到项目资源文件里用setStyleSheet加载界面气质完全不一样。最近网上流行的那种“国外很火的登录界面”多是大图背景、毛玻璃、左右分栏Qt里用圆角卡片加渐变背景也基本能实现个七八成。还是那句话先追求干净、可用、反馈清晰再谈视觉效果。3. 账号数据与登录校验逻辑实现3.1 SQLite用户表怎么建才够用既然决定用SQLite第一步是建库建表。我习惯把数据库文件放到系统的应用数据目录而不是程序可执行文件旁边避免安装到Program Files后没有写权限。QString dataDir QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(dataDir); QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(dataDir /auth.db); if (!db.open()) { qWarning() open database failed; return; }建表SQL长这样CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, salt BLOB NOT NULL, pass_hash BLOB NOT NULL, role TEXT NOT NULL DEFAULT user, disabled INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );字段解释一下salt和pass_hash是密码学存储需要的一对role用来区分管理员和普通用户disabled用来封禁账号这些字段越早留越好等用户量大了再改表结构麻烦得多。第一次启动时程序会检查表里有没有账号如果没有就自动创建一个默认管理员密码可以随机生成打印到控制台或者让管理员第一次启动时设置一定不要写死一个公开的默认密码。3.2 密码绝不能明文存加盐哈希这是整个登录模块最重要的安全点。把用户密码明文存到SQLite里相当于把钥匙放在门垫下面谁拿到数据库文件谁就能进。正确做法是加盐哈希先为每个用户生成一个随机盐再把“盐密码”一起做哈希运算盐和哈希结果都存进数据库。QByteArray generateSalt() { QByteArray salt(16, Qt::Uninitialized); for (int i 0; i salt.size(); i) { salt[i] static_castchar(QRandomGenerator::system()-bounded(256)); } return salt; } QByteArray hashPassword(const QString password, const QByteArray salt, int iterations 10000) { QByteArray data salt password.toUtf8(); for (int i 0; i iterations; i) { data QCryptographicHash::hash(data, QCryptographicHash::Sha256); } return data; }为什么不直接存QCryptographicHash::hash(password.toUtf8(), QCryptographicHash::Md5)因为直接哈希很容易被彩虹表撞库。加盐后每个用户的哈希值都不一样暴力破解成本大幅上升。迭代一万次是为了拖慢攻击者的尝试速度让每秒只能试几百次而不是几百万次。如果你要更严格的安全标准可以用OpenSSL的PKCS5_PBKDF2_HMAC来做PBKDF2派生效果更好。上面这个迭代SHA-256版本在内部工具场景已经比绝大多数“明文存密码”的程序强了。3.3 登录按钮背后的校验流程登录按钮点击后我在槽函数里按这个顺序处理检查用户名和密码不为空。从数据库查出salt、pass_hash、disabled字段。用户不存在、密码不匹配、账号被禁用都给出不同提示。连续失败次数达到阈值锁定按钮一段时间。校验通过后把当前用户名、角色等放进一个全局会话对象再调用accept()关闭登录框。核心代码类似void LoginDialog::onLoginClicked() { QString username m_userEdit-text().trimmed(); QString password m_pwdEdit-text(); if (username.isEmpty() || password.isEmpty()) { m_tipsLabel-setText(QStringLiteral(请输入用户名和密码)); return; } QSqlQuery query; query.prepare(SELECT salt, pass_hash, disabled, role FROM users WHERE username ?); query.addBindValue(username); if (!query.exec() || !query.next()) { m_tipsLabel-setText(QStringLiteral(用户名或密码错误)); m_pwdEdit-clear(); m_pwdEdit-setFocus(); return; } if (query.value(2).toInt() 1) { m_tipsLabel-setText(QStringLiteral(该账号已被禁用请联系管理员)); return; } QByteArray salt query.value(0).toByteArray(); QByteArray storedHash query.value(1).toByteArray(); QByteArray inputHash hashPassword(password, salt); if (inputHash ! storedHash) { m_failCount; m_tipsLabel-setText(QStringLiteral(用户名或密码错误已失败%1次).arg(m_failCount)); m_pwdEdit-clear(); m_pwdEdit-setFocus(); if (m_failCount 5) { m_loginBtn-setEnabled(false); QTimer::singleShot(30000, this, [this] { m_loginBtn-setEnabled(true); m_failCount 0; }); } return; } UserSession::instance().setUser(username, query.value(3).toString()); accept(); }这里有个小点值得注意查询用了prepareaddBindValue而不是字符串拼接这是防SQL注入的基本功。本地数据库虽然外部攻击面小但好习惯要从第一天养成。3.4 “记住密码”其实是个危险功能怎么做才体面很多登录界面会放一个“记住密码”复选框。我强烈不建议真的去存密码明文。更稳的方案是存一个“自动登录令牌”用户勾选自动登录后程序生成一个随机token数据库里只存token的哈希值和过期时间本地配置文件里存原始token。下次启动时程序带着token来换取登录态而不是带着密码来对比。这个方案的优点很明显即使有人拿到了本机配置文件也拿不到密码而且token可以单独吊销到期自动失效。实现上多了一个表CREATE TABLE IF NOT EXISTS login_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash BLOB NOT NULL, expires_at DATETIME NOT NULL );如果你的工具真的是内部低风险场景只想做“记住用户名”那也没问题。保存用户名到QSettings密码框留空用户只需要输密码体验和安全之间取一个平衡点。千万不要把密码用QSettings写入ini文件不管它看起来多方便。4. 把登录界面接入现有Qt程序4.1 改造main.cpp用exec()做启动闸门登录界面做得再好接不进主程序就是白搭。改造的重点在mian函数。一个典型的错误写法是// 错误示例 LoginDialog login; login.show(); MainWindow w; w.show();这样写登录框和主窗口会同时出现主窗口完全没被拦住。正确做法是让登录框以模态方式阻塞在main函数里int main(int argc, char *argv[]) { QApplication app(argc, argv); // 初始化数据库 if (!initDatabase()) { QMessageBox::critical(nullptr, QStringLiteral(错误), QStringLiteral(数据库初始化失败)); return 1; } // 尝试自动登录成功则跳过登录框 if (!tryAutoLogin()) { LoginDialog login; if (login.exec() ! QDialog::Accepted) { return 0; // 用户点取消或关窗口直接退出程序 } } MainWindow w; w.show(); return app.exec(); }这里的核心是exec()是一个“阻塞式”方法在它返回之前后面的代码不会执行。用户只有登录成功accept()或者取消reject()程序才会继续。这个模式理解以后整个程序的生命周期就清晰了。4.2 主界面的“切换账号”和“退出登录”程序跑起来以后用户可能会想退出登录换一个账号。我在MainWindow里加了一个菜单项点击后隐藏主窗口重新弹出登录框void MainWindow::onSwitchAccount() { if (QMessageBox::question(this, QStringLiteral(提示), QStringLiteral(确定要退出当前账号吗未保存的数据可能丢失)) ! QMessageBox::Yes) { return; } saveCurrentData(); // 记得先保存当前状态 this-hide(); LoginDialog login(this); if (login.exec() QDialog::Accepted) { setCurrentUser(UserSession::instance().username()); this-show(); } else { qApp-quit(); } }这个写法简单直接唯一的坑是登录框的父窗口是隐藏状态的主窗口exec()弹出来时可能会出现在奇怪的位置。我一般会先login.adjustSize()再算一下屏幕居中或者干脆用Qt::Dialog模态。4.3 用一个UserSession单例管理登录态一个登录态会在很多窗口里被用到比如状态栏显示当前用户、权限判断要不要禁用某个菜单、操作日志里记录是谁干的。与其把用户名当作参数在构造函数里传个遍不如做一个简单的单例class UserSession { public: static UserSession instance() { static UserSession s; return s; } void setUser(const QString username, const QString role) { m_username username; m_role role; m_loginTime QDateTime::currentDateTime(); } QString username() const { return m_username; } QString role() const { return m_role; } bool isLoggedIn() const { return !m_username.isEmpty(); } private: UserSession() default; QString m_username; QString m_role; QDateTime m_loginTime; };登录成功后调用UserSession::instance().setUser(...)任何窗口通过UserSession::instance().isLoggedIn()就能判断登录状态。这个单例不要存密码只存已经通过校验的身份信息这样即使被拿到也没有太大风险。5. 常见问题与排查技巧实录5.1 登录模块的经典翻车现场我做完这套登录模块后把踩过的坑和身边同事问过的问题整理成一个速查表遇到类似情况可以直接照单排查。现象常见原因解决办法运行时报QSqlDatabase: QSQLITE driver not loaded缺少SQLite驱动插件确认项目pro里加了QT sql发布时带上sqldrivers/qsqlite.dll打包给别人后双击程序没反应Qt运行库缺失用windeployqt部署并检查system32或应用程序目录是否缺少必要dll用户名密码输入中文变乱码源码文件编码或数据库编码不一致源码统一UTF-8Qt 5建议在main里设置QTextCodecSQLite连接后执行PRAGMA encoding UTF-8密码框按回车没反应忘记连接returnPressed信号connect(m_pwdEdit, QLineEdit::returnPressed, this, LoginDialog::onLoginClicked);登录成功后主窗口一闪而过main函数里主窗口变量被提前析构检查主窗口是不是栈对象并在某个作用域里被销毁必要时使用show()后进入app.exec()数据库文件被占用程序启动报错数据库连接未关闭或另一个进程锁住保证程序退出时关闭连接进程结束时db.close()排查是否有残留进程同一个Qt库版本混用导致崩溃手动拷贝了不同版本dll全部使用同一版本Qt如5.15.2就用5.15.2对应的运行库和插件不要混搭5.15.3其中那个驱动未加载的问题最典型。明明.pro文件里加了QT sql编译也通过一运行就报QSQLITE driver not loaded。很多情况下是因为插件路径不对发布时没有把plugins/sqldrivers目录拷贝过去。调试时可以在Qt Creator里正常跑但打包后单独运行就挂了。我的做法是在main函数里输出一下驱动列表qDebug() QSqlDatabase::drivers();如果列表里没有QSQLITE说明插件没被加载优先检查插件目录。5.2 关于模块报错和Qt版本兼容的特殊提醒搜索热词里经常能看到“unknown module(s) in qt: serialport”这类问题虽然它跟登录界面没有直接关系但能说明一个共性问题Qt项目用到的模块必须写在pro文件里。我做这个登录模块pro文件里至少要写QT core gui sql widgets如果不写sql#include QSqlDatabase也能编译但链接和运行时就会出各种莫名其妙的问题。另外热词里“cannot mix incompatible qt library (5.15.3) with this library (5.15.2)”这类错误在做登录界面项目层层打包时经常遇到。尤其是手动拷贝dll时一定确认所有Qt相关文件来自同一个版本目录否则登录窗口可能还没弹出来程序就崩了。最省心的做法是编译完成后用windeployqt生成发布目录再手动补额外的驱动库不要从网上东拼西凑。5.3 发布前必须检查的三个细节第一数据库目录要可写。不要默认把auth.db放在exe旁边程序安装到Program Files后普通用户没有写权限登录界面根本起不来。用QStandardPaths::AppDataLocation不同系统会自动映射到合适的目录。第二给登录框设置合适的父对象和模态方式。我在exec()之前设置窗口标志避免登录框出现在任务栏里或者被主窗口盖住setWindowModality(Qt::ApplicationModal);第三程序非正常退出后SQLite数据库可能处于WAL模式存在-wal和-shm文件。发布时不要只拷贝auth.db否则登录记录可能丢失。可以在数据库连接后固定journal模式为DELETE或者打包时把相关文件一起带上。6. 更进一步权限控制与登录审计6.1 给用户表加角色字段控制主界面的菜单登录不是为了挡人而是为了分权。如果程序里有些功能只有管理员能操作普通用户登录后应该看到置灰的按钮而不是靠主界面自己判断“这个人是谁”。我在users表里加了role字段默认是user管理员是admin。登录成功后UserSession保存角色主界面根据角色调用一个刷新权限的函数void MainWindow::applyUserPermission() { bool isAdmin (UserSession::instance().role() admin); m_btnAdminSettings-setVisible(isAdmin); m_menuUserManage-setEnabled(isAdmin); }这套逻辑比在按钮响应函数里一个个判断要清晰得多。6.2 登录日志和失败策略为了出问题时能追查我在登录模块里加了一张操作日志表记录每次登录的时间、用户名、结果成功/失败、失败原因。单机程序不需要太复杂的日志系统用一条INSERT写进SQLite就行。连续失败锁定这个策略前面已经演示过锁定的阈值和时长要根据团队使用习惯调整太严了会影响正常用户太松了又起不到作用。我的经验是内网工具设成“5次失败锁30秒”配合一个提示已经能挡住绝大多数手误和一部分暴力尝试。最后再说一个我实际开发里的体会登录功能看起来是程序的入口但本质上是整个程序的身份边界。把它做成一个独立的模块数据库和UserSession跟界面解耦后面无论是换一套QML界面还是改成对接企业账号系统都不用动主程序的核心代码。我一开始也是直接把登录框塞进MainWindow构造函数里结果每次打开主程序都要先初始化一堆乱七八糟的东西后来把登录流程挪到main函数入口代码结构立刻清爽了很多。如果你现在正在给旧项目加登录建议也按这个顺序来先跑通“弹框-校验-进主界面”的最小闭环再接数据库最后才调样式和做权限别一上来就折腾界面美化。
返回列表