登錄重構小記

語言: CN / TW / HK

前言

最近把小站的登錄頁面給重構了,之前的安全性存在很大問題,基本處於裸奔的狀態,特此記錄一下過程。

先説一下網站後端語言是php,為什麼用php呢,因為php是世界上最好的語言嗎,可能吧,不過最大的原因是因為我的網站託管在虛擬主機上,目前來説,幾乎所有廠商的虛擬主機都只支持php,不過本文所涉及到的php代碼都十分簡單,跟js沒啥區別。

本次規劃的登錄方式有三種,密碼登錄、手機驗證碼登錄、第三方登錄,接下來就一一來看一下。

界面

登錄界面通常來説都比較簡單,無非是幾個輸入框,對於筆者這種一線搬磚碼農來説不過是三下兩除二的事情,直接看最終效果:

Element UI和濃濃的QQ空間風交雜在一起有沒有。

行為驗證

現在大多數網站登錄前一般都會先進行人機驗證,從最早的輸入各種各樣字符驗證碼,到現在越來越流行的滑動拼圖驗證、文字點選驗證、無感驗證等等,阿里雲、網易、騰訊等等大廠都有提供行為驗證服務。

行為驗證一般由前端和後端配合進行驗證,單純的前端驗證並不安全,可以繞過,所以前端驗證通過後會生成token等標識,傳給後端,後端再調用服務商對應的接口來驗證。

行為驗證的原理可能涉及到機器學習什麼的,已經超出筆者的能力範圍,但作為使用方來説,具體使用方式一般服務商都會有詳細的例子和示例代碼,在此不贅述。

密碼登錄

密碼登錄是最傳統最歷史悠久的登錄方式了,註冊的時候把賬號密碼保存到數據庫,登錄的時候再進行比對,基本原則是不能明文傳輸、不能明文保存。

具體實現上,首先對密碼設定要求,暫定規則是長度八位到十六位,需要至少包含大小寫字母和數字,可包含部分特殊字符:$@$!%*#_~?&,前後端都進行校驗。

網站支持https的話可以不用考慮傳輸問題,但是我的虛擬主機並不支持,所以需要手動進行加密傳輸。

後端接收到密碼解密後再進行不可逆的加密存儲。

密碼規則驗證

直接通過正則表達式校驗即可,上述提到的密碼規則的其中一個正則表達式實現:/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[a-zA-Z0-9$@!.%*#_~?&]{8,16}$/,前面三個括號都是(?=p)的模式,p是一個子模式,?=用來匹配符合p模式之前的位置,整體含義是匹配以任意字符加小寫字母任意字符加大寫字母任意字符加數字開頭的八位以上的包含數字大小寫字母的字符串,其中的.*是必要的,否則上面的正則匹配不了任何字符,因為不可能有一個字符串能同時以大小寫字母及數字開頭。

加密傳輸

常用的加密方式有這幾種:MD5、對稱加密和非對稱加密,在這個場景下MD5不合適,因為它是把字符進行不可逆的編碼,那傳給服務端也解不開,再加上它並不安全,很多人也不認為它是一種加密算法;對稱加密的話加密和解密用的是同一個祕鑰,這意味着前端代碼裏也得內置這個祕鑰,那隻要打開源碼就能看到了所以也不安全,就只能選擇非對稱加密了。

非對稱加密有公鑰和私鑰兩個祕鑰,加密和解密分別選擇一個,其中一個加密的數據只能使用另外一個祕鑰來加密,這樣在前端就可以使用公鑰來加密,後端使用私鑰解密,公鑰就算被發現了沒有私鑰也沒用,目前最知名也最重要的就是RSA加密算法了,詳細瞭解可參考阮大神的文章:www.ruanyifeng.com/blog/2013/0…

RSA加密安全的代價之一就是慢,比對稱加密慢非常多,所以一般都是和對稱加密結合進行使用,比如https協議,傳輸的信息使用對稱加密算法進行加密,對稱加密的祕鑰使用非對稱加密方式來加密進行傳輸。另外,RSA加密的數據大小不能超過祕鑰長度,比如你的祕鑰長度為1024位,那麼所加密的數據最大不能超過1024/8=128字節,首先來按登錄場景來簡單計算一下。

以上面百科上的utf8編碼轉換表來寫一個簡單的計算字符字節數的方法如下:

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
}
複製代碼

賬號為手機號,也就是11個數字,字節大小計算出來為:11;密碼以最長16位計算出來字節大小約為:16,都遠小於128字節,所以可以直接使用RSA來進行加密,速度的話此處也可以忽略不計。

前端可以使用jsencrypt這個庫來進行rsa加密。在此之前需要先生成公鑰和私鑰,這個可以使用openssl命令行工具,openssl是一個開源的軟件工具包,用來實現TLS(傳輸層安全協議),同時包含了主要的加密算法、常用的密鑰和證書封裝管理等功能。

生成私鑰:

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)
複製代碼

然後把加密後的賬號和密碼發送到後端,後端進行解密,php解密代碼如下:

<?php 

function decryptRSA($str)
{
    // 讀取私鑰
    $private_key = openssl_pkey_get_private(RSA_PRIVATE);
    if (!$private_key) {
        return '私鑰有誤';
    }
    // 解密
    $return_de = openssl_private_decrypt(base64_decode($str), $decrypted, $private_key);
    if (!$return_de) {
        return ('解密失敗');
    }
    return $decrypted;
}
複製代碼

解密的時候要先使用base64_decode來進行解碼的原因是RSA加密後是二進制數據,不適合http傳輸,一般都會使用base64轉成字符串,從jsencrypt的源碼裏也能看出:

public encrypt(str:string) {
    // Return the encrypted string.
    try {
        return hex2b64(this.getKey().encrypt(str));
    } catch (ex) {
        return false;
    }
}
複製代碼

php解密得到賬號密碼後就可以去數據庫進行比對,這裏就需要先討論一下密碼是如何加密存儲的。

密碼存儲

我們經常會聽到某某公司的數據庫泄漏了的消息,數據庫泄漏最可怕的是什麼,除了用户個人信息之外就是密碼了,因為現在的各種網站APP實在是太多了,每個都要設置密碼,所以大多數人都是一個密碼走天下,那麼如果密碼被別人獲取了是很可怕的事情,所以密碼存儲一定是不可逆的。

最簡單的是直接對密碼使用md5加密,但是常用密碼很容易就被反向查詢出來了,稍微進階一點的是把密碼和一個複雜的隨機字符串,俗稱鹽先拼接起來,再進行md5,這樣反向查詢出來的概率就比較低了,但是如果鹽也被竊取了,那人家同樣也可以先加鹽再進行反向查詢,所以為了增加破解難度,每個密碼的鹽值都是不一樣的,鹽值和密碼通常是存儲在一起的。但是以現在計算機的計算能力來説破解起來還是比較容易的,所以又出現了一種叫PBKDF2的方法,簡單説來就是進行N次md5,次數越多,破解的耗時也越久,當破解一個密碼都需要耗時很久,那麼總的代價會是巨大的。還有一種是bcrypt算法,可以通過參數調整計算強度,被認為是比PBKDF2更安全的。

以上這些php都有內置函數可以支持,但是限於我所用的php版本PBKDF2bcrypt函數都不支持,所以只能選擇自己實現一個簡單的PBKDF2方法。

使用PBKDF2算法一般都會選擇使用sha系列hash算法,本文選擇sha1,hash它個1000次。

<?php 

// 生成隨機字符
function randomStr($len){
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
    $charsLen = strlen($chars) - 1;
    $str = '';
    for($i = 0; $i < $len; $i++) {
        $str .= $chars[mt_rand(0,$charsLen)];
    }
    return $str;
}
複製代碼

php生成鹽應該有更安全的方法,但是搜索了一圈,都沒找都合適的方法,所以只能這樣簡單寫一個。

接下來要實現的是PBKDF2方法,基本邏輯是原始密碼和鹽進行hash,將得到的hash值再和原始密碼進行hash,這樣循環hash,直到你需要的次數。

<?php 

function PBKDF2HASH($password, $salt, $count)
{
    $curSalt = $salt;
    for($i = 0; $i < $count; $i++) {
        $curSalt = sha1($password . $curSalt);
    }
    return $curSalt;
}
複製代碼

之後再把生成的hash值和鹽值一同保存到數據庫,登錄時再把鹽值取出來進行上述的hash操作,比對最後生成的值是否一致即可。

維持登錄狀態

登錄成功後需要保持登錄狀態,因為http是無狀態協議,所以催生了cookie的誕生,cookie就是一段文本,保持在客户端本地,每次發送http請求時客户端都會把它帶到請求頭裏,這樣服務端就可以通過cookie來判斷本次會話用户的信息。

一般登錄成功後服務端會設置一個只允許http訪問的cookie,內容一般是一個id,然後把用户信息和這個id關聯起來,這些數據可以保持在內存裏(通常使用redis數據庫)或者持久化到MySql等數據庫,下次請求時根據這id來判斷有沒有登錄信息。

php裏使用session變量可以很容易實現這個需求:

<?php 

session_start();

$_SESSION['uid'] = xxx;
複製代碼

使用session_start註冊一個新會話或者重用現有會話,然後給超級全局變量$_SESSION設置一個鍵值,具體要保存什麼數據因你而定,我這裏只保存一個用户id,用户其他的信息根據id再去數據庫裏查詢。

設置完後下次收到請求時獲取和退出登錄時的銷燬也很簡單:

<?php

session_start();
// 獲取
$uid = $_SESSION['uid'];
// 銷燬
$_SESSION['uid'] = null;
session_destroy();// 通常來説不需要調用這個方法
複製代碼

當然上述是最簡單的方式,缺點也很明顯,瀏覽器關閉或者一段時間後就需要重新登錄,另外對單點登錄也不太友好。

要想讓登錄更持久可以設置cookie的有效期和session過期時間長一點:

<?php

// 設置session_id的cookie,五個參數:過期時間,單位s、路徑path、域domain、是否僅在https時可用、是否httponly
session_set_cookie_params(3 * 3600, '/', '.lxqnsys.com', false, true);
// 設置session生存時間
ini_set("session.gc_maxlifetime", 3 * 3600);

session_start();
// ...
複製代碼

但是過期時間設置的太久是一件又風險的事情,所以最好還是考慮使用其他方式。

另一種維持登錄狀態的方式是使用JWT(json web token),這種方式簡單來説就是登錄成功後把認證信息都返回給客户端,由客户端進行存儲,每次http請求時也帶上,服務端不需要存儲任何數據,而是從中取出需要的東西,當然,這個token是有生成規則的,分三部分組成,偽代碼如下:

// 元信息
const header = base64UrlEncode({
    "alg": "HS256",
    "typ": "JWT"
}
// 內容主體
const payload = base64UrlEncode({
    // 可以選用預定義字段,也可以添加自定義字段
})
// 簽名,用來檢查數據是否被篡改了,secret是祕鑰,不能泄露
const signature = HMACSHA256(`${header}.${payload}`, secret)
// 組成最終的token
const token = `${header}.${payload}.${signature}`
複製代碼

可以看到生成的token是沒有加密的,所以不能放敏感信息,硬要放的話需要對token再做一層加密。

更多詳細信息可參考:www.ruanyifeng.com/blog/2018/0…

短信登錄

短信登錄也是現在很普及的一種登錄方式,有些網站甚至只支持短信登錄,因為發短信是要錢的,所以一定需要做一些限制措施,圖形驗證之類的是肯定要的,另外還要限制發送頻率,比如1分鐘或2分鐘之內只能發送一條,以及同一個手機號一天之內只能發送多少條。

驗證碼和時間限制也可以使用session來保存:

<?php

// 保存
$sessionArray = array();
$sessionArray['phoneNumber'] = $phoneNumber;
$sessionArray['code'] = $code;
$sessionArray['lastTime'] = time();
$_SESSION['verificationCode'] = $sessionArray;
複製代碼

再次收到請求時從session取出來判斷手機號、驗證碼、時間是否都正確合法。至於限制手機號一天發送的量因為服務商自帶就有這個功能,所以就不自己做了。

第三方登錄

最後一種要實現的方式是第三方登錄,這也是目前很流行的一種登錄方式,這種方式的好處是你不需要向當前網站提供第三方網站的賬號和密碼就可以獲取到第三方網站裏的一些用户信息,這樣在當前網站就可以不用通過麻煩的註冊來創建賬號及登錄,但是有少數網站你選擇了第三方登錄以及登錄成功後還立馬要讓你填手機號密碼什麼的再註冊一遍,不講武德,簡直智障,我就是圖方便才登錄第三方賬號,完了你還要我註冊,説白了就是想要我手機號,如果不是什麼非必須的網站,一般到這一步我就跟它説再見了。

第三方登錄簡單來説就是先跳轉去登錄第三方網站,登錄成功後會把一些信息如用户唯一的id、暱稱、頭像什麼的返回給當前網站,當前網站可以根據這些信息來創建新賬號或者完成登錄,這其中涉及到的是一個叫做OAuth 2.0的協議,這個協議有點長,裏面規定了四種實現方式,有興趣的可以自行百度閲讀,反正我從來沒有讀完過。不過目前各大網站的接入方式都是基本一致的,總結如下:

1.去第三方網站的開放平台註冊賬號,填寫應用信息,填寫回調地址,獲取一下app keyapp secret

2.在你的網站上點擊第三方網站的圖標或按鈕後跳轉到第三方提供的登錄地址,帶上app key以及上一步填寫的回調地址,登錄成功後回跳轉回回調地址頁面,並帶上一個code

3.通過上一步獲取到的code去請求第三方提供的接口獲取令牌

4.通過上一步獲取到的令牌再去請求第三方提供的接口獲取用户信息

接下來我們以掘金上的第三方登錄github賬號來實現一下。

第一步去github上註冊應用github.com/settings/ap…

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3c6d53ea8d3f4d03931b52201fcf4d5a~tplv-k3u1fbpfcp-zoom-1.image

最後一個要輸入的就是我們的回調地址。

第二步在我們的網站上添加第三方登錄的按鈕,一般都是使用對方的logo

點擊後跳轉到github的登錄地址,掘金上點擊後會彈出一個小窗口:

這可以使用window.open方法,不過有一些需要注意的點,如果只是簡單的使用:

let url = `https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=http://xxx.com/`;
window.open(url)
複製代碼

默認下是直接新開一個tab,而不是以小窗口的形式打開,想要以小窗口打開的話第三個參數不能為空,也就是你要設置一下新開窗口的樣式:

window.open(url, '_blank', 'width=600, height=600')
複製代碼

但是經測試,瀏覽器全屏的情況下一般仍然是新開一個tab,並且各個瀏覽器的效果可能都不一樣,所以不要期待能有一致的效果了。

看一下掘金登錄時小窗口上的地址信息:

https://github.com/login?client_id=60483ab971aa5416e000&return_to=/login/oauth/authorize?client_id=60483ab971aa5416e000&redirect_uri=https://juejin.cn/passport/auth/login_success&scope=user:email&state=4b4b89193gASoVCgoVPZIGM4MDY0MzZmNjJlNDlhMTc1NjBmNjg1MDU3MWUxNWM2oU6-aHR0cHM6Ly9qdWVqaW4uY24vb2F1dGgtcmVzdWx0oVYBoUkAoUQAoUHRCjChTdEKMKFIqWp1ZWppbi5jbqFSBKJQTNEEFaZBQ1RJT06goUyyaHR0cHM6Ly9qdWVqaW4uY24voVTZIDEwNDlkOTIyYTE1YjUyOTdkMTA5NTk5M2UxZThiM2EwoVcAoUYAolNBAKFVww==
複製代碼

可以看到掘金的回調地址為:https://juejin.cn/passport/auth/login_success,另外還有幾個參數,scope參數表示要求的授權範圍,這裏表示掘金除了基礎信息外還想獲取用户的電子郵件地址,state是一個字符串,最後會原封不動的傳回給你,可以用來判斷是否被修改了,更多信息可參考github的開發文檔:docs.github.com/cn/develope…

如果用户登錄成功就會重定向到回調地址,但是問題來了,回調地址只能填寫一個,但是在掘金的任何頁面都可以進行登錄,而且登錄成功後會自動刷新當前頁面。

首先點擊了第三方登錄按鈕後掘金會在localStorage上存儲當前的登錄發起頁面的地址:

其次是監聽子窗口的關閉,關閉了當前頁面就進行刷新:

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似乎並沒有什麼用,的確,扒了一下小窗口頁面的源碼發現了下面的這段代碼:

可以發現存儲的這個url只在微信環境下才用的到。但是如果你的登錄頁是一個單獨的頁面,那麼還是用的上的。

在回調地址頁面獲取到返回的code之後需要換取令牌,通過後端請求對應接口:

<?php

$code = $_POST['code'];
$data = array(
    'client_id' => 'xxx',
    'client_secret' => 'xxx',
    'code' => $code,
    'redirect_uri' => 'xxx'
);
// post為一個發送post請求的方法,不是php的內置函數
post('https://github.com/login/oauth/access_token', $data);
複製代碼

獲取到令牌就可以再去請求獲取用户信息:

<?php

$header = array('Authorization: token ' . $access_token, 'User-Agent: 理想青年實驗室');
// get為一個發送get請求的方法,不是php的內置函數
get('https://api.github.com/user', $header);
複製代碼

獲取到用户信息就可以根據裏面的用户唯一的id字段的值來創建賬號、關聯賬號以及進行登錄。

總結

本文簡單記錄了一下一個常見登錄頁面的一些知識點,存在錯誤或安全問題的話還請指出,登錄可以説的東西還有很多,比如如何實現免登錄、掃碼登錄、單點登錄、app客户端等的登錄等等,因為目前沒有相關實踐,所以也無從介紹,各位有興趣可以自行了解,再會。