註冊、登陸和 token 的安全之道

最近想要作一個小項目,因爲先後都是一我的,在登陸和註冊的接口上就被卡住了,所以想登陸、註冊、口令之間的關係,使用 PHP 實現登陸註冊模塊,和訪問口令。javascript

出於安全的考慮,首先定下三項原則:php

  1. 在傳輸中,不容許明文傳輸用戶隱私數據;
  2. 在本地,不容許明文保存用戶隱私數據;
  3. 在服務器,不容許明文保存用戶隱私數據;

在網絡來講,咱們知道不論 POST 請求和 GET 請求都會被抓包,在沒有使用 HTTPS 的狀況下,抓包咱們是防不住的,若是明文傳輸用戶隱私,那後果就不說了。java

本地和服務器也是如此,好比 iOS 設備,若是存儲在本地,越獄以後經過設備 Finder 之類的功能,也能輕易找到咱們存儲在本地的用戶隱私。python

使用 Keychain 在本地也有保存,但不在沙盒,暫且忽略。mysql

上面講到,用戶隱私數據總歸能夠被拿到的,如何保證被拿到以後不會被用來作壞事?算法

加密

將用戶的隱私數據加密,那麼就算被拿到,也沒法被拿來使用。在這裏呢,咱們先不談加密,而是先糾正一個誤區,有些朋友會認爲 Base64 能夠加密,甚至有 Base64 加密的說法。sql

Base64 主要不是加密,它主要的用途是把二進制數據序列轉化爲 ASCII 字符序列,用以數據傳輸。二進制數據是什麼呢?計算機上存儲的全部數據,都是二進制數據。數據庫

Base64 最多見的應用場景是 URL,由於 URL 只能是特定的一些 ASCII 字符。這時須要用到 Base64 編碼,固然這也只是對二進制數據自己的編碼,編碼後的數據裏面可能包含 +/= 等符號,真正放到 URL 裏面時候,還須要URL-Encoding,變成 %XX 模式,以消除這些符號的歧義。其次就是將圖片轉爲 Base64 的字符串。json

所以,Base64 只是一種編碼方式,而不是加密方式。swift

好了,如今回到咱們的主題,先說登陸和註冊之間的關係,這 3 個模塊須要作什麼事情呢?

  • 註冊:將用戶輸入的隱私數據,發送給服務器,服務器進行保存;
  • 登陸:將用戶輸入的隱私數據,發送給服務器,服務器進行比對,確認是否有權限登陸;
  • token:確保用戶在登陸中;

咱們把用戶輸入的隱私數據再具象一些,好比帳號和密碼,結合咱們上面提到的安全原則,那麼分解開來,實際咱們要作如下幾件事:

  • 服務器-註冊接口:接收客戶端傳來的帳號和密碼,將其保存在數據庫中;
  • 服務器-登陸接口:接收客戶端傳來的帳號和密碼,與數據庫比對,徹底命中則登陸成功,不然登陸失敗;
    • 登陸成功後,生成或更新 token 和過時時間,保存在數據庫, token 返回給客戶端;
    • 服務器按期清除 token;
  • 客戶端-註冊模塊:向服務器註冊接口發送帳號和密碼;
  • 客戶端-登陸模塊:向服務器登陸接口發送帳號和密碼;
    • 登陸成功後,保存 token 到本地;
    • 退出登陸後,清除 token;
  • 發送的帳號和密碼須要加密;
  • 數據庫中須要保存的是加密後的帳號和密碼;
  • 請求敏感數據時,將客戶端傳來的 token 和服務器驗證,不經過則提示客戶端登陸;

上面邏輯理清楚後,相信對於你們來講並不難實現,如下是服務器註冊接口作的事情:

/*獲取 get 請求傳遞的參數*/
$account = $_GET['account'];
$password = $_GET['password'];

/*建立數據鏈接*/
$db = new DataBase();

/*檢查用戶名是否存在*/
$is_exist = $db->check_user_exist($account);

if ($is_exist) {
    echo return_value(10001, false);
}
else {
    /*檢查用戶名是否添加成功*/
    $result = $db->add_user($account, $password);
    if ($result) {
        echo return_value(0, true);
    }
    else {
        echo return_value(20001, false);
    }
}複製代碼

如今是服務器登陸接口作的事情:

/*獲取 get 請求傳遞的參數*/
$account = $_GET['account'];
$password = $_GET['password'];

/*建立數據鏈接*/
$db = new DataBase();

/*是否命中用戶名和密碼*/
$should_login = $db->should_login($account, $password);

if ($should_login) {
    /*更新 token*/
    $token = $db->insert_token($account);
    if ($token == '') {
        echo response(40001, false);
    }
    else {
        $data = ['token' => $token];
        echo response(0, $data);
    }
}
else {
    echo response(30001, false);
}複製代碼

剩下的無非是加密算法的不一樣,我最經常使用的是 md5,那麼咱們通過 md5 加密之後,其實仍是不太安全,爲何呢?由於 md5 自己就不安全。雖然 md5 是不可逆的 hash 算法,反向算出來雖然困難,可是若是反向查詢,密碼設置的簡單,也很容易被攻破。

好比咱們使用 md5 加密一個密碼 123456,對應的 md5 是 e10adc3949ba59abbe56e057f20f883e,找到一個 md5 解密的網站,好比 cmd5.com/,很容易就被破解了密碼…

加鹽

工做一段時間的同窗對這個名詞應該不會陌生,這種方式算是給用戶的隱私數據加上密了,其實就是一段隱私數據加一段亂碼再進行 md5,用代碼表示大體是這樣:

// 僞代碼
salt = '#^&%**(^&(&*)_)_(*&^&#$%GVHKBJ(*^&*%^%&^&'
password = '123456'
post_body = salt + password
print post_body.md5()
// ffb34d898f6573a1cf14fdc34d3343c0複製代碼

如今,密碼看起來挺靠譜的了,可是,咱們知道加鹽這種方式是比較早期的處理方式了,既然如今沒有在大範圍使用了,就說明單純加鹽仍是存在缺陷的。

有泄露的可能

如今咱們在客戶端對密碼作了 md5 加鹽,服務器保存的也是加密後的內容,可是,鹽是寫在了客戶端的源代碼中,一旦對源代碼進行反編譯,找到 salt 這個字符串,那麼加鹽的作法也就形同虛設了。

反編譯源代碼的代價也很高,通常對於安全性能要求不高的話,也夠用了,可是,對於一些涉及資金之類的 App 來講,僅僅加鹽仍是不夠的。

好比離職的技術同窗不是很開心,又或者有人想花錢買這串字符等等,鹽一旦被泄露,就是一場災難,這也是鹽最大的缺陷。

依賴性太強

鹽一旦被設定,那麼再作修改的話就很是困難了,由於服務器存儲的所有是加鹽後的數據,若是換鹽,那麼這些數據所有都須要改動。可是可怕的不在於此,若是將服務器的數據改動後,舊版本的用戶再訪問又都不能夠了,由於他們用的是以前的鹽。

HMAC

目前最多見的方式,應該就是 HMAC 了,HMAC 算法主要應用於身份驗證,與加鹽的不一樣點在於,鹽被移到了服務器,服務器返回什麼,就用什麼做爲鹽。

這麼作有什麼好處呢? 若是咱們在登陸的過程當中,黑客截獲了咱們發送的數據,他也只能獲得 HMAC 加密事後的結果,因爲不知道密鑰,根本不可能獲取到用戶密碼,從而保證了安全性。

可是還有一個問題,前面咱們講到,鹽被獲取之後很危險,若是從服務器獲取鹽,也會被抓包,那還不如寫在源代碼裏面呢,至少被反編譯還困難點,那若是解決這個隱患呢

那就是,在用戶註冊時就生成和獲取這個祕鑰,以代碼示例:

如今咱們發送一個請求:

GET http://localhost:8888/capsule/register.php?account=joy&password=789複製代碼

服務器收到請求後,作了下面的事情:

/*獲取 get 請求傳遞的參數*/
$account = $_GET['account'];
$password = $_GET['password'];  //123456

/*建立數據鏈接*/
$db = new DataBase();

/*製做一個隨機的鹽*/
$salt = salt();

/*檢查用戶名是否存在*/
$is_exist = $db->check_user_exist($account);

if ($is_exist) {
    echo response(10001, false);
}
else {

    /*將密碼進行 hmac 加密*/
    $password = str_hmac($password,  $salt);

    /*檢查用戶名是否添加成功*/
    $result = $db->add_user($account, $password);

    if ($result) {
        $data = ['salt'=>$salt];
        echo response(0, $data);
        //echo response(0, true);
    }
    else {
        echo response(20001, false);
    }
}複製代碼

服務器如今保存的是:

account: joy
password: 05575c24576複製代碼

客戶端拿到的結果是:

{
  "rc": 0,
  "data": {
    "salt": "5633905fdc65b6c57be8698b1f0e884948c05d7f"
  },
  "errorInfo": ""
}複製代碼

那麼客戶端接下來應該作什麼呢?把 salt 作本地的持久化,登陸時將用戶輸入的密碼作一次一樣的 hmac,那麼就能經過服務器的 password: 05575c24576 校驗了,發起登陸請求:

GET http://localhost:8888/capsule/login.php?account=joy&password=789 
// fail
GET http://localhost:8888/capsule/login.php?account=joy&password=05575c24576 
// success複製代碼

如今咱們解決了依賴性太強的問題,鹽咱們能夠隨意的更改,甚至能夠是隨機的,每一個用戶都不同。這樣單個用戶的安全性雖然沒有增強,可是整個平臺的安全性缺大大提高了,不多有人會針對一個用戶搞事情。可是細心的同窗應該能夠想到,如今的鹽,也就是祕鑰是保存在本地的,若是用戶的祕鑰丟失,好比換手機了,那麼豈不是有正確的密碼,也沒法登錄了嗎

針對這個問題,核心就是用戶沒有了祕鑰,那麼在用戶登錄的時候,邏輯就須要變一下。

// 僞代碼
func login(account, password) {
    //若是有鹽
    if let salt = getSalt() {
        //將密碼進行 hmac,請求登錄接口
        network.login(account, password.hmac(salt))
    }
    else {
        //請求 getSalt 接口,請求參數爲帳戶+應用標識
        network.getSalt(account + bundleId, { salt in
            //將鹽保存在本地,再次調用自身。
            savaSalt(salt)
            login(account, password)
        })
    }
}複製代碼

那麼可想而知,咱們的註冊接口如今也須要新加一個 bundleId 的請求參數,而後用 account + bundleId 做爲 key,來保存 salt

/*獲取 get 請求傳遞的參數*/
$account = $_GET['account'];
$password = $_GET['password'];  //123456
$bundle_id = $_GET['bundleId'];

/*建立數據鏈接*/
$db = new DataBase();

/*製做一個隨機的鹽*/
$salt = salt();

/*檢查用戶名是否存在*/
$is_exist = $db->check_user_exist($account);

if ($is_exist) {
    echo response(10001, false);
}
else {
    /*將密碼進行 hmac 加密*/
    $password = str_hmac($password,  $salt);

    /*檢查用戶名是否添加成功*/
    $result = $db->add_user($account, $password);

    if ($result) {

        /*檢查祕鑰是否保存成功*/
        $save_salt = $db->save_salt($salt, $account, $bundle_id);

        if ($save_salt) {
            $data = ['salt'=>$salt];
            echo response(0, $data);
        }
        else {
            echo response(20001, false);
        }
    }
    else {
        echo response(20001, false);
    }
}複製代碼

同時咱們須要建立一個獲取 salt 的接口:

/*獲取 get 請求傳遞的參數*/
$account = $_GET['account'];
$bundle_id = $_GET['bundleId'];

/*建立數據鏈接*/
$db = new DataBase();

/*獲取祕鑰*/
$salt = $db->get_salt($account, $bundle_id);

if ($salt == '') {
    echo response(40001, false);
}
else {
    $data = ['salt'=>$salt];
    echo response(0, $data);
}複製代碼

寫到這裏,就能夠給你們介紹一個比較好玩的東西了。

設備鎖

一些 App 具備設備鎖的功能,好比 QQ,這個功能是將帳號與設備進行綁定,若是其餘人知道了用戶的帳號和密碼,可是設備不符,一樣沒法登陸,怎樣實現呢?

就是用戶開啓設備鎖以後,若是設備中沒有 salt,那麼就再也不請求 getSalt 接口,而是轉爲其餘驗證方式,經過以後,才能夠請求 getSalt

提高單個用戶的安全性

如今這個 App 相對來講比較安全了,上面說到,由於每一個用戶的 salt 都不同,破解單個用戶的利益不大,因此,對於平臺來講安全性已經比較高了,但凡是都有例外,若是這個破壞者就是鐵了心要搞事情,就針對一個用戶,如今這個方案,還有哪些問題存在呢?

  1. 註冊時返回的 salt 被抓包時有可能會泄露;
  2. 更換設備後,獲取的 salt 被抓包時有可能會泄露;
  3. 保存在本地的 salt ,有可能經過文件路徑獲取到;
  4. 抓包的人就算不知道密碼,經過 hmac 加密後的字符,也能夠進行登陸;

    怎麼處理呢?首先咱們須要清楚的是,之因此會被破解,是拿到了咱們加密時的因子,或者叫種子,這個種子服務器和客戶端都必需要有,若是沒有的話,二者就沒法進行通訊了,可是咱們也不能在客戶端將種子寫死,在服務器給客戶端種子時,總會有可能被獲取。

咱們要設計一種思路,須要有一個種子,服務器和客戶端之間無需通信,可是均可以被理解的種子。

同時咱們須要這個種子是動態的,每次加密的結果都不同,那麼就算抓到了加密後的密碼,這個密碼也隨之失效了。

因此,咱們須要一個無需服務器和客戶端通信的,動態的種子,時間。

HMAC+時間

這個動態的種子是如何使用的呢?

  1. 客戶端發送註冊請求,服務器返回 salt,保存 hmac 後的密碼;
  2. 客戶端保存 salt
  3. 客戶端發送登陸請求,參數爲 hmac 後的密碼,加上當前的時間;
  4. 服務器收到登陸請求,將數據庫中的密碼,加上當前的時間,進行比對;

客戶端代碼:

// 祕鑰
const salt = ''
// 當前時間,精確到分鐘
const currentTime = '201709171204'
// 用戶輸入的密碼
let password = '123456'
// (hmac+currentTime).md5
password = (password.hmac(salt) + currentTime).md5()
network('login', {method: 'GET', params: {password:password}})複製代碼

服務器代碼:

function should_login($account, $password) {
    $account = mysqli_real_escape_string($this->connection ,$account);
    $password = mysqli_real_escape_string($this->connection, $password);
    $user = $this->get_user($account);
    if ($user == null) {
        return false;
    }
    $password_local = $user['password'];
    if ($password_local == '') {
        return false;
    }
    $password_local = md5($password_local.current_time());
    if ($password_local == $password) {
        return true;
    }
    else {
        return false;
    }
}複製代碼

可是如今還有一點問題,那就是對時間的容錯上,若是客戶端發送的時候是 201709171204,服務器響應時卻已經到了 201709171205 了,那麼這樣勢必是不能經過的,這種狀況,只須要服務器把當前的時間減去一分鐘,再校驗一次,符合其中之一就能夠。

聰明的你應該能夠想到,這也就是驗證碼 5 分鐘內有效期的實現

如今這個 App,就算註冊時拿到了 salt,也很難在 1 分鐘內反推出密碼,同時,抓包的密碼一分鐘後也就失效了,對於單個用戶的安全性,也有了進一步的提高。

相關文章
相關標籤/搜索