:從哈希生成到緩存清理的完整流程)
后臺忘了密碼這種事做運維和開發(fā)的幾乎都躲不掉。尤其是一些運行了好幾年的內(nèi)部系統(tǒng)用戶表躺在MySQL里密碼字段還是不知道哪年留下的舊格式偏偏當(dāng)事人急得不行在群里催著“能不能幫我把密碼找回來”。這里先把話說明白我們通過基于Linux中的數(shù)據(jù)庫操作能做的并不是把原來的明文密碼“找”出來而是為用戶生成一個新的密碼哈希并寫進庫里——也就是用戶密碼找回這個系列里最核心的“重置密碼”動作。這是系列第二篇上一篇重點講了怎么定位數(shù)據(jù)庫、確認(rèn)用戶表、在命令行里安全連上MySQL這篇我們進入實際作戰(zhàn)環(huán)節(jié)直接從連接數(shù)據(jù)庫開始到生成合規(guī)哈希、更新字段、清緩存、驗登錄走完一整套可復(fù)現(xiàn)的流程讓讀者能照著把自己手頭的系統(tǒng)救回來。文章里我會用一套模擬環(huán)境舉例某內(nèi)部系統(tǒng)數(shù)據(jù)庫是MySQL用戶表叫users目標(biāo)用戶是zhang。所有命令都在Linux終端里執(zhí)行。如果你手里用的是MariaDB、Percona或PostgreSQL思路完全一樣只是少數(shù)語法需要微調(diào)。1. 先想清楚一件事你在數(shù)據(jù)庫里只能“重置密碼”不是“找回密碼”1.1 密碼存儲的進化與明文遺留問題很多人一聽到“找回密碼”第一反應(yīng)是能不能從數(shù)據(jù)庫里把原來的密碼解出來。如果碰到存明文的系統(tǒng)理論上你確實能直接讀出來但碰到這種系統(tǒng)我的建議不是高興而是警惕——明文密碼意味著整個體系的密碼管理還停留在非常原始的階段就算你把這一次的密碼告訴用戶密碼也可能早就在日志、備份、監(jiān)控腳本里泄露過幾輪了?,F(xiàn)代系統(tǒng)里密碼幾乎不會以明文存儲在數(shù)據(jù)庫里。常見做法是拿一個單向哈希函數(shù)把密碼變成一串定長字符串再把哈希值存進用戶表。因為哈希函數(shù)不可逆數(shù)據(jù)庫管理員能做的只有一件事生成一個新密碼對應(yīng)的哈希寫進表里覆蓋舊值。這聽起來很簡單實際執(zhí)行時你會撞上算法識別、字段長度、應(yīng)用校驗邏輯、緩存穿透等一系列問題一顆顆拆完才能真正讓用戶登錄進去。所以先調(diào)整心態(tài)我們不是在恢復(fù)舊密碼而是在給用戶發(fā)一張“新入場券”。這張入場券必須符合應(yīng)用自己的校驗規(guī)則否則你改了數(shù)據(jù)庫用戶照樣登錄不上。1.2 認(rèn)識常見的密碼哈希格式動手之前先學(xué)會“認(rèn)哈希”。我見過太多次新手直接UPDATE一個MD5進去結(jié)果應(yīng)用用的是password_verify()校驗看到MD5直接拒絕。哈希格式是判斷系統(tǒng)年代和框架類型的重要線索常見的無非下面幾類32位十六進制字符串比如e10adc3949ba59abbe56e057f20f883e這是MD5多見于2005年前后的老PHP應(yīng)用、論壇程序、舊版自研系統(tǒng)。40位十六進制字符串比如5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8這是SHA1在一些老Java系統(tǒng)或部分舊版系統(tǒng)里出現(xiàn)。64位十六進制字符串通常是SHA2家族的SHA-256可能是自定義存儲也可能是部分開發(fā)框架早期版本。以$6$開頭長度接近106個字符這是基于SHA-512的crypt格式多用于Linux系統(tǒng)賬號或PAM認(rèn)證偶爾也會出現(xiàn)在老式Web應(yīng)用里。以$2y$、$2a$或$2b$開頭長度固定60字符左右這是bcrypt現(xiàn)代PHP框架用得非常普遍。以$argon2id$開頭長度比較長這是Argon2id較新的框架或安全要求較高的系統(tǒng)才用。我的習(xí)慣是先連上數(shù)據(jù)庫執(zhí)行一條查詢把目標(biāo)用戶當(dāng)前password字段的前幾位和長度看一眼再決定后面的動作。字段前綴和長度能幫你快速排除七八成的不確定性避免拿著錯誤哈希盲試。2. 本次實戰(zhàn)的完整SQL操作從定位用戶到更新哈希2.1 連接數(shù)據(jù)庫并確認(rèn)用戶表結(jié)構(gòu)先把數(shù)據(jù)庫連上。內(nèi)部系統(tǒng)一般會提供只讀賬號用于排查但我們要做UPDATE必須有寫權(quán)限。我通常這樣登錄mysql -h 127.0.0.1 -u admin -p appdb管理員賬號的權(quán)限以最小夠用為原則不建議直接拿root在業(yè)務(wù)庫上折騰。登錄后第一件事不是改數(shù)據(jù)而是摸清表結(jié)構(gòu)DESC users;重點確認(rèn)幾個字段username、password或password_hash、updated_at、登錄失敗次數(shù)相關(guān)字段、鎖定狀態(tài)字段。如果用戶表設(shè)計得比較現(xiàn)代還會有password_changed_at、must_change_password之類的字段這些字段一會兒全都要照顧到。在修改任何數(shù)據(jù)之前先把目標(biāo)用戶的數(shù)據(jù)備份出來。這一步不要省因為在真實故障中你無法預(yù)知這次改動會牽動多少邏輯。一條簡單SQL就能做CREATE TABLE users_bak_20260601 AS SELECT * FROM users;或者用mysqldump只導(dǎo)出目標(biāo)用戶那一行恢復(fù)起來更輕量mysqldump -h 127.0.0.1 -u admin -p appdb users \ --whereusernamezhang zhang_backup.sql確認(rèn)目標(biāo)用戶存在并且沒有被意外鎖定的字段牽絆SELECT id, username, LEFT(password, 7) AS pwd_prefix, LENGTH(password) AS pwd_len, locked_until, login_fail_count FROM users WHERE username zhang;這條查詢能看出很多東西密碼哈希前綴、長度、鎖定時間、失敗次數(shù)這些信息決定了你后面清理的順序。2.2 生成一個能通過應(yīng)用校驗的新密碼哈希生成哈希的第一選擇永遠是用應(yīng)用自己提供的入口而不是憑感覺造一個字符串。很多框架自帶命令行工具比如Django項目的manage.pyshell進去之后可以直接調(diào)set_passwordLaravel也有tinker這類入口生成出來的哈希和框架自身的校驗邏輯完全匹配是最不容易出錯的路。但不少老系統(tǒng)的框架入口已經(jīng)殘缺不全或者開發(fā)環(huán)境不在手邊這時候就必須在Linux終端里手工生成哈希。下面幾個命令是我實測過最常用的用PHP生成bcrypt哈希php -r echo password_hash(Temp2024, PASSWORD_BCRYPT), PHP_EOL;輸出類似$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy這是現(xiàn)代PHP框架最認(rèn)的格式。執(zhí)行前確認(rèn)系統(tǒng)里PHP版本不是太老PHP 5.5以上才帶password_hash()。用Python生成bcrypt哈希python3 -c import bcrypt; print(bcrypt.hashpw(bTemp2024, bcrypt.gensalt(rounds12)).decode())輸出同樣以$2b$或$2y$開頭。如果你的Python環(huán)境沒裝bcrypt先執(zhí)行pip install bcrypt或者退回去用PHP方案。用htpasswd生成bcrypt哈希htpasswd -bnBC 10 Temp2024 | tr -d :\nhtpasswd的-B強制使用bcrypt-C 10設(shè)置cost為10輸出格式是“冒號哈?!庇胻r把冒號和換行去掉就得到一串純bcrypt哈希。這種方式適合系統(tǒng)里沒裝PHP和Python的極端環(huán)境前提是Apache工具箱里有htpasswd。有一點必須強調(diào)這里選的臨時密碼復(fù)雜度至少要抗住字典攻擊。我的習(xí)慣是用Temp加日期加隨機數(shù)比如Temp2026#Kd9z既方便用戶臨時使用又不會弱到讓人一眼看穿。2.3 執(zhí)行UPDATE并用事務(wù)兜底生成哈希之后把它嵌進UPDATE語句。為了讓整個操作可控我習(xí)慣開一個顯式事務(wù)START TRANSACTION; UPDATE users SET password $2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy, updated_at NOW(), login_fail_count 0, locked_until NULL WHERE username zhang; SELECT username, LEFT(password, 7) AS pwd_prefix, LENGTH(password) AS pwd_len FROM users WHERE username zhang; COMMIT;執(zhí)行后再看一眼SELECT結(jié)果確認(rèn)哈希前綴正確、長度合理。login_fail_count清零和locked_until置空是為了避免用戶一登錄就撞上“密碼錯誤次數(shù)過多”的鎖。如果users表沒有這些字段就跳過不影響。我強烈建議所有寫操作都放在事務(wù)里套一層。萬一UPDATE之后發(fā)現(xiàn)哈希生成錯了還能ROLLBACK回滾而不是手忙腳亂地去找備份。這里唯一的例外是如果表是MyISAM引擎事務(wù)會失效那就提前用備份方案兜底。3. 只能純命令行時如何判斷該用哪種哈希3.1 哈希特征速查表不是所有系統(tǒng)都那么配合有時候你連應(yīng)用日志都找不到只有一臺Linux服務(wù)器和一個MySQL終端這時判斷哈希類型就成了解救系統(tǒng)的第一步。我把常見哈希做成了一張速查表建議保存一份哈希特征算法類型常見使用場景32位十六進制MD5老式PHP論壇、舊自研系統(tǒng)40位十六進制SHA1舊Java或舊Python系統(tǒng)64位十六進制SHA2-256自定義存儲以$1$開頭34字符MD5 crypt老式類Unix認(rèn)證以$6$開頭約106字符SHA-512 cryptLinux系統(tǒng)賬號、PAM應(yīng)用以$2y$/$2a$/$2b$開頭60字符bcrypt現(xiàn)代框架以$argon2id$開頭Argon2id較新、安全要求較高的系統(tǒng)判斷口訣很簡單看前綴、數(shù)字?jǐn)?shù)、查表。大多數(shù)情況下一個系統(tǒng)里所有用戶的哈希格式是一致的你可以多查幾條記錄交叉驗證確認(rèn)自己有沒有判斷錯。3.2 沒有PHP/Python時的替代生碼方式如果機器上既沒有PHP也沒有Python甚至連htpasswd都不存在此時需要分場景處理目標(biāo)是Linux系統(tǒng)賬號直接使用passwd zhang或echo Temp2024 | chpasswd即可系統(tǒng)會自動生成合規(guī)的哈希寫入/etc/shadow根本不需要手工算哈希。目標(biāo)是Web應(yīng)用用戶那么系統(tǒng)里大概率裝過運行應(yīng)用所需的語言運行時。如果一個都沒有建議先別急著UPDATE優(yōu)先考慮部署環(huán)境是否本身就不完整免得繞過了應(yīng)用校驗邏輯后續(xù)埋下更大隱患。真的只能在純Shell環(huán)境生成SHA-512 crypt哈希時可以用OpenSSLopenssl passwd -6 Temp2024生成的結(jié)果以$6$開頭適配Linux系統(tǒng)賬號但對現(xiàn)代Web應(yīng)用基本無效只作為極端場景的兜底。3.3 遇到完全未知的哈希格式怎么辦哈希格式完全無法判斷的時候最穩(wěn)妥的做法不是猜而是從應(yīng)用源碼里找答案。Linux服務(wù)器上一般能定位到應(yīng)用目錄進到代碼里搜password相關(guān)函數(shù)看它調(diào)用的是password_verify、md5()、sha256()還是自己寫的校驗函數(shù)。定位到校驗函數(shù)后你才知道UPDATE進去的字符串需要滿足什么格式。如果源碼實在找不到還有一個可靠的“黑盒測試法”在測試環(huán)境或者非業(yè)務(wù)高峰時臨時插入一個測試用戶走一遍注冊流程觀察注冊成功后在數(shù)據(jù)庫里password字段長成什么樣然后用同一規(guī)則生成目標(biāo)用戶的哈希。這個方法在舊系統(tǒng)上成功率非常高因為注冊和登錄用的是同一套校驗函數(shù)。4. 密碼更新成功之后必須做的四件收尾事4.1 清掉影響登錄的緩存與鎖定狀態(tài)數(shù)據(jù)庫字段更新成功不代表用戶馬上就能登錄。現(xiàn)代系統(tǒng)普遍加了緩存層用戶信息、登錄狀態(tài)很可能被扔在Redis或Memcached里。此時如果你不清理應(yīng)用讀取的依然是舊密碼哈希用戶照樣登錄失敗。如果檢測到Redis在運行先找出用戶相關(guān)的緩存鍵。沒有統(tǒng)一規(guī)范的話可以用redis-cli --scan --pattern *zhang*掃一遍確認(rèn)之后執(zhí)行DEL清理。常見的鍵名格式有user:info:zhang、user:profile:1、auth:user:zhang等刪完再讓用戶嘗試登錄。登錄失敗次數(shù)和鎖定狀態(tài)也不要漏。前面UPDATE里我已經(jīng)把login_fail_count和locked_until清理掉了但如果系統(tǒng)把失敗計數(shù)放在Redis里還得另外DEL相關(guān)鍵。在這個環(huán)節(jié)寧可多掃一輪也不要放過任何一個可能緩存舊狀態(tài)的角落。4.2 重啟相關(guān)服務(wù)并用真實登錄驗證清理完緩存后重啟應(yīng)用依賴的服務(wù)。我的習(xí)慣是把PHP-FPM和Web服務(wù)器一起平滑重啟sudo systemctl restart php-fpm nginx如果系統(tǒng)帶了隊列或常駐進程和認(rèn)證相關(guān)的也要一并考慮。重啟之后不要只看數(shù)據(jù)庫里值對不對要模擬真實登錄流程走一遍。最簡單的方式是用curl把登錄請求打一遍curl -i -X POST http://127.0.0.1/login \ -H Content-Type: application/x-www-form-urlencoded \ -d usernamezhangpasswordTemp2024觀察HTTP返回碼和頁面跳轉(zhuǎn)邏輯。如果返回302跳轉(zhuǎn)到用戶中心說明這一環(huán)已經(jīng)打通。如果返回200但頁面內(nèi)有“密碼錯誤”字樣說明哈希格式與應(yīng)用校驗邏輯不匹配回到第3節(jié)重新判斷。同時開一個日志跟蹤窗口邊測試邊看應(yīng)用日志tail -f /var/log/app/app.log /var/log/php-fpm/error.log重點找password相關(guān)錯誤很多問題日志里寫得比表面現(xiàn)象清楚得多。4.3 關(guān)注binlog、權(quán)限與觸發(fā)器做完主要驗證步驟后我會額外檢查幾個容易被忽略的地方。第一是binlog。MySQL開啟binlog后你執(zhí)行的UPDATE會記錄到binlog文件里新密碼的哈希直接躺在日志中。如果這是一個審計嚴(yán)格的環(huán)境這個哈希等于被記錄了一份存根。匹配的場景下你需要和數(shù)據(jù)庫負(fù)責(zé)人溝通確認(rèn)binlog的保留策略并評估是否需要輪轉(zhuǎn)或清理。涉及敏感操作的服務(wù)器可以考慮把這類UPDATE放到維護窗口統(tǒng)一執(zhí)行。第二是權(quán)限。盡量不用rootlocalhost操作業(yè)務(wù)表更不要在命令行里直接帶密碼參數(shù)那樣密碼哈希會進Shell history。用mysql -u admin -p交互式登錄或者通過MySQL配置文件里的[client]段指定憑據(jù)能減少不少泄露面。第三是觸發(fā)器。有些系統(tǒng)在users表上掛了BEFORE UPDATE觸發(fā)器自動更新updated_at或?qū)懭雽徲嫳?。你先?zhí)行SHOW TRIGGERS LIKE users%;看看有沒有觸發(fā)器否則你手動在UPDATE里寫的updated_at NOW()可能被覆蓋或者還多出一條你不知情的密碼歷史記錄。這不是壞事但你得知道。4.4 給用戶一個“臨時密碼必須改”的機制密碼重置完畢另一個容易被忽略的動作是強制用戶下次登錄改密。如果應(yīng)用支持must_change_password之類的字段記得把這個字段順手置為1UPDATE users SET must_change_password 1 WHERE username zhang;如果應(yīng)用沒有這個字段只能讓用戶登錄后手動到個人中心修改或者約定這個臨時密碼僅當(dāng)天有效次日由你再次更新。不管哪種方式都要明確告訴用戶臨時密碼只在首次登錄時有效登錄后請立即修改。同時把更新后的哈希記入密碼歷史表。如果系統(tǒng)有password_histories表舊密碼哈希和新密碼哈希最好都留一份防止用戶把密碼改回舊值。這不僅能滿足安全策略也是事后復(fù)盤時的重要依據(jù)。5. 我踩過的三個坑以及建議的密碼字段設(shè)計5.1 坑一bcrypt哈希被VARCHAR(60)截斷早年間很多系統(tǒng)的密碼字段定義是VARCHAR(60)當(dāng)時設(shè)計者認(rèn)為bcrypt哈希固定60字符剛好放得下。問題是某些實現(xiàn)擴容后哈希更長比如bcrypt在高版本實現(xiàn)里可能輸出更長信息或者系統(tǒng)切換到了Argon2id60字符根本裝不下。我遇到過一位同事執(zhí)行UPDATE后MySQL報Data too long for column password整條語句直接失敗。當(dāng)時系統(tǒng)還算安全因為沒有禍害到線上數(shù)據(jù)。從那以后我養(yǎng)成了先DESC users再動手的習(xí)慣看到password字段長度低于VARCHAR(255)先擴容ALTER TABLE users MODIFY password VARCHAR(255) NOT NULL;做這類結(jié)構(gòu)變更之前確認(rèn)應(yīng)用代碼沒對字段長度做死校驗。有些老應(yīng)用會在代碼里寫死取前60位那就得改應(yīng)用不是改表能解決的。5.2 坑二哈希前綴不一致登錄永遠失敗有一次我手工生成了一串以$2y$開頭的bcrypt哈希寫入一個老系統(tǒng)。按理說bcrypt都是兼容的但那個應(yīng)用寫死了只認(rèn)$2a$前綴驗證環(huán)節(jié)直接返回失敗。用戶卡在登錄頁半個多小時我排查半天才發(fā)現(xiàn)是前綴問題。這個教訓(xùn)告訴我只要條件允許就別手工造哈希盡量走應(yīng)用自己的入口。如果只能手工造要先確認(rèn)應(yīng)用代碼里用的校驗函數(shù)是什么。PHP的老版本crypt()對$2y$支持不佳而現(xiàn)代password_verify()多數(shù)場景能兼容前綴差異但業(yè)務(wù)系統(tǒng)的實現(xiàn)五花八門絕不能想當(dāng)然。5.3 坑三改了數(shù)據(jù)庫登錄還是走舊緩存還有一次踩得更深。數(shù)據(jù)庫里明明已經(jīng)更新成新哈希Redis里也掃過一遍偏偏用戶登錄還是提示密碼錯誤。最后排查發(fā)現(xiàn)應(yīng)用在登錄成功時會把用戶對象緩存到本地內(nèi)存而那個常駐Worker進程一直沒重啟內(nèi)存里保留的仍是舊密碼哈希。所以“清Redis”不等于清一切緩存。如果能定位到應(yīng)用進程管理方式重啟相關(guān)服務(wù)是最穩(wěn)妥的兜底?,F(xiàn)在我做密碼重置后標(biāo)準(zhǔn)動作就是清Redis、重啟PHP-FPM和相關(guān)常駐進程、再跑一次真實登錄腳本。寧可多花兩分鐘重啟也不要把臟狀態(tài)留給用戶去撞。5.4 一個能少踩這些坑的表結(jié)構(gòu)建議吃了足夠多的虧之后我現(xiàn)在給新系統(tǒng)設(shè)計用戶密碼表時會直接采用下面這套結(jié)構(gòu)CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, email VARCHAR(255) NULL, password_hash VARCHAR(255) NOT NULL, password_algo VARCHAR(32) NOT NULL DEFAULT bcrypt, password_changed_at DATETIME NULL, login_fail_count INT NOT NULL DEFAULT 0, locked_until DATETIME NULL, must_change_password TINYINT(1) NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_login_fail (login_fail_count, locked_until) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;配套的密碼歷史表CREATE TABLE password_histories ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, password_hash VARCHAR(255) NOT NULL, replaced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;這套設(shè)計的關(guān)鍵點有三個password_hash給足255長度避免哈希擴長后截斷password_algo記錄生成算法后續(xù)排查哈希格式時一眼可見password_changed_at和must_change_password組成“臨時密碼強制修改”機制。寫完這套表結(jié)構(gòu)后再遇到用戶密碼找回的需求我一般只需要五步備份、生成哈希、事務(wù)UPDATE、清緩存重啟服務(wù)、跑登錄驗證。整個過程不超過十五分鐘。這些經(jīng)驗都是被真實故障一個個喂出來的希望能幫你少踩幾顆雷。