最近把小站的登錄頁面給重構(gòu)了,之前的安全性存在很大問題,基本處于裸奔的狀態(tài),特此記錄一下過程。
創(chuàng)新互聯(lián)公司是一家專業(yè)提供松北企業(yè)網(wǎng)站建設(shè),專注與網(wǎng)站設(shè)計制作、網(wǎng)站設(shè)計、H5響應(yīng)式網(wǎng)站、小程序制作等業(yè)務(wù)。10年已為松北眾多企業(yè)、政府機(jī)構(gòu)等服務(wù)。創(chuàng)新互聯(lián)專業(yè)網(wǎng)站制作公司優(yōu)惠進(jìn)行中。
先說一下網(wǎng)站后端語言是php
,為什么用php
呢,因為php
是世界上最好的語言嗎,可能吧,不過最大的原因是因為我的網(wǎng)站托管在虛擬主機(jī)上,目前來說,幾乎所有廠商的虛擬主機(jī)都只支持php
,不過本文所涉及到的php
代碼都十分簡單,跟js
沒啥區(qū)別。
本次規(guī)劃的登錄方式有三種,密碼登錄、手機(jī)驗證碼登錄、第三方登錄,接下來就一一來看一下。
登錄界面通常來說都比較簡單,無非是幾個輸入框,對于筆者這種一線搬磚碼農(nóng)來說不過是三下兩除二的事情,直接看最終效果:
Element UI
和濃濃的QQ空間風(fēng)交雜在一起有沒有。
現(xiàn)在大多數(shù)網(wǎng)站登錄前一般都會先進(jìn)行人機(jī)驗證,從最早的輸入各種各樣字符驗證碼,到現(xiàn)在越來越流行的滑動拼圖驗證、文字點選驗證、無感驗證等等,阿里云、網(wǎng)易、騰訊等等大廠都有提供行為驗證服務(wù)。
行為驗證一般由前端和后端配合進(jìn)行驗證,單純的前端驗證并不安全,可以繞過,所以前端驗證通過后會生成token等標(biāo)識,傳給后端,后端再調(diào)用服務(wù)商對應(yīng)的接口來驗證。
行為驗證的原理可能涉及到機(jī)器學(xué)習(xí)什么的,已經(jīng)超出筆者的能力范圍,但作為使用方來說,具體使用方式一般服務(wù)商都會有詳細(xì)的例子和示例代碼,在此不贅述。
密碼登錄是最傳統(tǒng)最歷史悠久的登錄方式了,注冊的時候把賬號密碼保存到數(shù)據(jù)庫,登錄的時候再進(jìn)行比對,基本原則是不能明文傳輸、不能明文保存。
具體實現(xiàn)上,首先對密碼設(shè)定要求,暫定規(guī)則是長度八位到十六位,需要至少包含大小寫字母和數(shù)字,可包含部分特殊字符:$@$!%*#_~?&
,前后端都進(jìn)行校驗。
網(wǎng)站支持https的話可以不用考慮傳輸問題,但是我的虛擬主機(jī)并不支持,所以需要手動進(jìn)行加密傳輸。
后端接收到密碼解密后再進(jìn)行不可逆的加密存儲。
直接通過正則表達(dá)式校驗即可,上述提到的密碼規(guī)則的其中一個正則表達(dá)式實現(xiàn):/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9$@!.%*#_~?&]{8,16}$/
,前面三個括號都是(?=p)
的模式,p
是一個子模式,?=
用來匹配符合p
模式之前的位置,整體含義是匹配以任意字符加小寫字母
、任意字符加大寫字母
和任意字符加數(shù)字
開頭的八位以上的包含數(shù)字大小寫字母的字符串,其中的.*
是必要的,否則上面的正則匹配不了任何字符,因為不可能有一個字符串能同時以大小寫字母及數(shù)字開頭。
常用的加密方式有這幾種:MD5、對稱加密和非對稱加密,在這個場景下MD5不合適,因為它是把字符進(jìn)行不可逆的編碼,那傳給服務(wù)端也解不開,再加上它并不安全,很多人也不認(rèn)為它是一種加密算法;對稱加密的話加密和解密用的是同一個秘鑰,這意味著前端代碼里也得內(nèi)置這個秘鑰,那只要打開源碼就能看到了所以也不安全,就只能選擇非對稱加密了。
非對稱加密有公鑰和私鑰兩個秘鑰,加密和解密分別選擇一個,其中一個加密的數(shù)據(jù)只能使用另外一個秘鑰來加密,這樣在前端就可以使用公鑰來加密,后端使用私鑰解密,公鑰就算被發(fā)現(xiàn)了沒有私鑰也沒用,目前最知名也最重要的就是RSA加密算法了,詳細(xì)了解可參考阮大神的文章:http://www.ruanyifeng.com/blog/2013/06/rsa_algorithm_part_one.html。
RSA加密安全的代價之一就是慢,比對稱加密慢非常多,所以一般都是和對稱加密結(jié)合進(jìn)行使用,比如https
協(xié)議,傳輸?shù)男畔⑹褂脤ΨQ加密算法進(jìn)行加密,對稱加密的秘鑰使用非對稱加密方式來加密進(jìn)行傳輸。另外,RSA加密的數(shù)據(jù)大小不能超過秘鑰長度,比如你的秘鑰長度為1024位,那么所加密的數(shù)據(jù)最大不能超過1024/8=128字節(jié),首先來按登錄場景來簡單計算一下。
以上面百科上的utf8編碼轉(zhuǎn)換表來寫一個簡單的計算字符字節(jié)數(shù)的方法如下:
function strLen (str) {
let len = 0
for(let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i)
if (code <= 0x007f) {
len += 1
} else if (code <= 0x07ff) {
len += 2
} else if (code <= 0xffff) {
len += 3
} else {
len += 4
}
}
return len
}
賬號為手機(jī)號,也就是11個數(shù)字,字節(jié)大小計算出來為:11;密碼以最長16位計算出來字節(jié)大小約為:16,都遠(yuǎn)小于128字節(jié),所以可以直接使用RSA來進(jìn)行加密,速度的話此處也可以忽略不計。
前端可以使用jsencrypt這個庫來進(jìn)行rsa
加密。在此之前需要先生成公鑰和私鑰,這個可以使用openssl
命令行工具,openssl
是一個開源的軟件工具包,用來實現(xiàn)TLS(傳輸層安全協(xié)議)
,同時包含了主要的加密算法、常用的密鑰和證書封裝管理等功能。
生成私鑰:
openssl genrsa -out lx_rsa_1024_priv.pem 1024
查看上一步生成的私鑰:
cat lx_rsa_1024_priv.pem
獲取上述私鑰的公鑰:
openssl rsa -pubout -in lx_rsa_1024_priv.pem -out lx_rsa_1024_pub.pem
查看上一步生成的公鑰:
cat lx_rsa_1024_pub.pem
保存好私鑰和公鑰,接下來前端使用公鑰來加密,安裝jsencrypt
:
npm i jsencrypt
加密代碼:
import Jsencrypt from 'jsencrypt';
const rsa_pub = 'xxx'// 公鑰
const password = 'xxx'
encrypt.setPublicKey(rsa_pub)
let encryptedPassword = encrypt.encrypt(password)
然后把加密后的賬號和密碼發(fā)送到后端,后端進(jìn)行解密,php
解密代碼如下:
解密的時候要先使用base64_decode
來進(jìn)行解碼的原因是RSA
加密后是二進(jìn)制數(shù)據(jù),不適合http
傳輸,一般都會使用base64
轉(zhuǎn)成字符串,從jsencrypt
的源碼里也能看出:
public encrypt(str:string) {
// Return the encrypted string.
try {
return hex2b64(this.getKey().encrypt(str));
} catch (ex) {
return false;
}
}
php
解密得到賬號密碼后就可以去數(shù)據(jù)庫進(jìn)行比對,這里就需要先討論一下密碼是如何加密存儲的。
我們經(jīng)常會聽到某某公司的數(shù)據(jù)庫泄漏了的消息,數(shù)據(jù)庫泄漏最可怕的是什么,除了用戶個人信息之外就是密碼了,因為現(xiàn)在的各種網(wǎng)站APP實在是太多了,每個都要設(shè)置密碼,所以大多數(shù)人都是一個密碼走天下,那么如果密碼被別人獲取了是很可怕的事情,所以密碼存儲一定是不可逆的。
最簡單的是直接對密碼使用md5
加密,但是常用密碼很容易就被反向查詢出來了,稍微進(jìn)階一點的是把密碼和一個復(fù)雜的隨機(jī)字符串,俗稱鹽先拼接起來,再進(jìn)行md5
,這樣反向查詢出來的概率就比較低了,但是如果鹽也被竊取了,那人家同樣也可以先加鹽再進(jìn)行反向查詢,所以為了增加破解難度,每個密碼的鹽值都是不一樣的,鹽值和密碼通常是存儲在一起的。但是以現(xiàn)在計算機(jī)的計算能力來說破解起來還是比較容易的,所以又出現(xiàn)了一種叫PBKDF2
的方法,簡單說來就是進(jìn)行N次md5
,次數(shù)越多,破解的耗時也越久,當(dāng)破解一個密碼都需要耗時很久,那么總的代價會是巨大的。還有一種是bcrypt
算法,可以通過參數(shù)調(diào)整計算強(qiáng)度,被認(rèn)為是比PBKDF2
更安全的。
以上這些php
都有內(nèi)置函數(shù)可以支持,但是限于我所用的php
版本PBKDF2
和bcrypt
函數(shù)都不支持,所以只能選擇自己實現(xiàn)一個簡單的PBKDF2
方法。
使用PBKDF2
算法一般都會選擇使用sha
系列hash
算法,本文選擇sha1
,hash它個1000次。
~`+=,.;:/?|';
$charsLen = strlen($chars) - 1;
$str = '';
for($i = 0; $i < $len; $i++) {
$str .= $chars[mt_rand(0,$charsLen)];
}
return $str;
}
php
生成鹽應(yīng)該有更安全的方法,但是搜索了一圈,都沒找都合適的方法,所以只能這樣簡單寫一個。
接下來要實現(xiàn)的是PBKDF2
方法,基本邏輯是原始密碼和鹽進(jìn)行hash,將得到的hash值再和原始密碼進(jìn)行hash,這樣循環(huán)hash,直到你需要的次數(shù)。
之后再把生成的hash值和鹽值一同保存到數(shù)據(jù)庫,登錄時再把鹽值取出來進(jìn)行上述的hash操作,比對最后生成的值是否一致即可。
登錄成功后需要保持登錄狀態(tài),因為http
是無狀態(tài)協(xié)議,所以催生了cookie
的誕生,cookie
就是一段文本,保持在客戶端本地,每次發(fā)送http
請求時客戶端都會把它帶到請求頭里,這樣服務(wù)端就可以通過cookie
來判斷本次會話用戶的信息。
一般登錄成功后服務(wù)端會設(shè)置一個只允許http
訪問的cookie
,內(nèi)容一般是一個id
,然后把用戶信息和這個id
關(guān)聯(lián)起來,這些數(shù)據(jù)可以保持在內(nèi)存里(通常使用redis
數(shù)據(jù)庫)或者持久化到MySql
等數(shù)據(jù)庫,下次請求時根據(jù)這id
來判斷有沒有登錄信息。
在php
里使用session
變量可以很容易實現(xiàn)這個需求:
使用session_start
注冊一個新會話或者重用現(xiàn)有會話,然后給超級全局變量$_SESSION
設(shè)置一個鍵值,具體要保存什么數(shù)據(jù)因你而定,我這里只保存一個用戶id
,用戶其他的信息根據(jù)id
再去數(shù)據(jù)庫里查詢。
設(shè)置完后下次收到請求時獲取和退出登錄時的銷毀也很簡單:
當(dāng)然上述是最簡單的方式,缺點也很明顯,瀏覽器關(guān)閉或者一段時間后就需要重新登錄,另外對單點登錄也不太友好。
要想讓登錄更持久可以設(shè)置cookie
的有效期和session
過期時間長一點:
但是過期時間設(shè)置的太久是一件又風(fēng)險的事情,所以最好還是考慮使用其他方式。
另一種維持登錄狀態(tài)的方式是使用JWT(json web token)
,這種方式簡單來說就是登錄成功后把認(rèn)證信息都返回給客戶端,由客戶端進(jìn)行存儲,每次http
請求時也帶上,服務(wù)端不需要存儲任何數(shù)據(jù),而是從中取出需要的東西,當(dāng)然,這個token
是有生成規(guī)則的,分三部分組成,偽代碼如下:
// 元信息
const header = base64UrlEncode({
"alg": "HS256",
"typ": "JWT"
}
// 內(nèi)容主體
const payload = base64UrlEncode({
// 可以選用預(yù)定義字段,也可以添加自定義字段
})
// 簽名,用來檢查數(shù)據(jù)是否被篡改了,secret是秘鑰,不能泄露
const signature = HMACSHA256(`${header}.${payload}`, secret)
// 組成最終的token
const token = `${header}.${payload}.${signature}`
可以看到生成的token
是沒有加密的,所以不能放敏感信息,硬要放的話需要對token
再做一層加密。
更多詳細(xì)信息可參考:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html。
短信登錄也是現(xiàn)在很普及的一種登錄方式,有些網(wǎng)站甚至只支持短信登錄,因為發(fā)短信是要錢的,所以一定需要做一些限制措施,圖形驗證之類的是肯定要的,另外還要限制發(fā)送頻率,比如1分鐘或2分鐘之內(nèi)只能發(fā)送一條,以及同一個手機(jī)號一天之內(nèi)只能發(fā)送多少條。
驗證碼和時間限制也可以使用session
來保存:
再次收到請求時從session
取出來判斷手機(jī)號、驗證碼、時間是否都正確合法。至于限制手機(jī)號一天發(fā)送的量因為服務(wù)商自帶就有這個功能,所以就不自己做了。
最后一種要實現(xiàn)的方式是第三方登錄,這也是目前很流行的一種登錄方式,這種方式的好處是你不需要向當(dāng)前網(wǎng)站提供第三方網(wǎng)站的賬號和密碼就可以獲取到第三方網(wǎng)站里的一些用戶信息,這樣在當(dāng)前網(wǎng)站就可以不用通過麻煩的注冊來創(chuàng)建賬號及登錄,但是有少數(shù)網(wǎng)站你選擇了第三方登錄以及登錄成功后還立馬要讓你填手機(jī)號密碼什么的再注冊一遍,不講武德,簡直智障,我就是圖方便才登錄第三方賬號,完了你還要我注冊,說白了就是想要我手機(jī)號,如果不是什么非必須的網(wǎng)站,一般到這一步我就跟它說再見了。
第三方登錄簡單來說就是先跳轉(zhuǎn)去登錄第三方網(wǎng)站,登錄成功后會把一些信息如用戶唯一的id
、昵稱、頭像什么的返回給當(dāng)前網(wǎng)站,當(dāng)前網(wǎng)站可以根據(jù)這些信息來創(chuàng)建新賬號或者完成登錄,這其中涉及到的是一個叫做OAuth 2.0
的協(xié)議,這個協(xié)議有點長,里面規(guī)定了四種實現(xiàn)方式,有興趣的可以自行百度閱讀,反正我從來沒有讀完過。不過目前各大網(wǎng)站的接入方式都是基本一致的,總結(jié)如下:
1.去第三方網(wǎng)站的開放平臺注冊賬號,填寫應(yīng)用信息,填寫回調(diào)地址,獲取一下app key
和app secret
2.在你的網(wǎng)站上點擊第三方網(wǎng)站的圖標(biāo)或按鈕后跳轉(zhuǎn)到第三方提供的登錄地址,帶上app key
以及上一步填寫的回調(diào)地址,登錄成功后回跳轉(zhuǎn)回回調(diào)地址頁面,并帶上一個code
3.通過上一步獲取到的code
去請求第三方提供的接口獲取令牌
4.通過上一步獲取到的令牌再去請求第三方提供的接口獲取用戶信息
接下來我們以掘金上的第三方登錄github
賬號來實現(xiàn)一下。
第一步去github
上注冊應(yīng)用https://github.com/settings/applications/new:
最后一個要輸入的就是我們的回調(diào)地址。
第二步在我們的網(wǎng)站上添加第三方登錄的按鈕,一般都是使用對方的logo
:
點擊后跳轉(zhuǎn)到github
的登錄地址,掘金上點擊后會彈出一個小窗口:
這可以使用window.open
方法,不過有一些需要注意的點,如果只是簡單的使用:
let url = `https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=http://xxx.com/`;
window.open(url)
默認(rèn)下是直接新開一個tab,而不是以小窗口的形式打開,想要以小窗口打開的話第三個參數(shù)不能為空,也就是你要設(shè)置一下新開窗口的樣式:
window.open(url, '_blank', 'width=600, height=600')
但是經(jīng)測試,瀏覽器全屏的情況下一般仍然是新開一個tab,并且各個瀏覽器的效果可能都不一樣,所以不要期待能有一致的效果了。
看一下掘金登錄時小窗口上的地址信息:
https://github.com/login?client_id=ab971aa5416e000&return_to=/login/oauth/authorize?client_id=ab971aa5416e000&redirect_uri=https://juejin.cn/passport/auth/login_success&scope=user:email&state=4b4bgASoVCgoVPZIGM4MDY0MzZmNjJlNDlhMTc1NjBmNjg1MDU3MWUxNWM2oU6-aHR0cHM6Ly9qdWVqaW4uY24vb2F1dGgtcmVzdWx0oVYBoUkAoUQAoUHRCjChTdEKMKFIqWp1ZWppbi5jbqFSBKJQTNEEFaZBQ1RJT06goUyyaHR0cHM6Ly9qdWVqaW4uY24voVTZIDEwNDlkOTIyYTE1YjUyOTdkMTA5NTk5M2UxZThiM2EwoVcAoUYAolNBAKFVww==
可以看到掘金的回調(diào)地址為:https://juejin.cn/passport/auth/login_success
,另外還有幾個參數(shù),scope
參數(shù)表示要求的授權(quán)范圍,這里表示掘金除了基礎(chǔ)信息外還想獲取用戶的電子郵件地址,state
是一個字符串,最后會原封不動的傳回給你,可以用來判斷是否被修改了,更多信息可參考github
的開發(fā)文檔:https://docs.github.com/cn/developers/apps/authorizing-oauth-apps。
如果用戶登錄成功就會重定向到回調(diào)地址,但是問題來了,回調(diào)地址只能填寫一個,但是在掘金的任何頁面都可以進(jìn)行登錄,而且登錄成功后會自動刷新當(dāng)前頁面。
首先點擊了第三方登錄按鈕后掘金會在localStorage
上存儲當(dāng)前的登錄發(fā)起頁面的地址:
其次是監(jiān)聽子窗口的關(guān)閉,關(guān)閉了當(dāng)前頁面就進(jìn)行刷新:
this.windowObj = window.open(url, '_blank', 'width=600, height=600')
this.onCloseCheck()
onCloseCheck() {
if (!this.windowObj) {
return
}
clearTimeout(this.closeCheckTimer)
this.closeCheckTimer = setTimeout(() => {
if(this.windowObj.closed) {
location.reload()
clearTimeout(this.closeCheckTimer)
this.windowObj = null
} else {
this.onCloseCheck()
}
}, 500);
}
這樣看起來這個存儲的url
似乎并沒有什么用,的確,扒了一下小窗口頁面的源碼發(fā)現(xiàn)了下面的這段代碼:
可以發(fā)現(xiàn)存儲的這個url
只在微信環(huán)境下才用的到。但是如果你的登錄頁是y
在回調(diào)地址頁面獲取到返回的code
之后需要換取令牌,通過后端請求對應(yīng)接口:
'xxx',
'client_secret' => 'xxx',
'code' => $code,
'redirect_uri' => 'xxx'
);
// post為一個發(fā)送post請求的方法,不是php的內(nèi)置函數(shù)
post('https://github.com/login/oauth/access_token', $data);
獲取到令牌就可以再去請求獲取用戶信息:
獲取到用戶信息就可以根據(jù)里面的用戶唯一的id
字段的值來創(chuàng)建賬號、關(guān)聯(lián)賬號以及進(jìn)行登錄。
本文簡單記錄了一下一個常見登錄頁面的一些知識點,存在錯誤或安全問題的話還請指出,登錄可以說的東西還有很多,比如如何實現(xiàn)免登錄、掃碼登錄、單點登錄、app客戶端等的登錄等等,因為目前沒有相關(guān)實踐,所以也無從介紹,各位有興趣可以自行了解,再會。