當 Cookie 被竄改會怎麼樣?

就糟糕啦~

簡述

上次在 利用 Cookie 實作登入機制 中已經實作出一個基本的登入功能,但文末有提過這樣會有 cookie 被竄改的問題。這篇文章就來解釋一下是什麼問題?以及該如何解決?

假冒別人身分

既然 cookie 值可以任我改,那只要知道其他人的 username 是什麼就可以假冒身分了:

problem

補充一下「新增留言」的程式邏輯,不然會看不太懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* handle_add_comments.php */
<?php
require_once('./conn.php');
require_once('./utils.php');
// 透過 cookie 拿到目前的 user
$username = $_COOKIE['username'];
// 撈出 user 資料
$user = getUser($username);
// 從 user 資料中找出暱稱的欄位
$nickname = $user['nickname'];
// 用 POST 帶過來的留言內容
$content = $_POST['content'];

// 檢查留言內容
if (empty($content)) {
header('Location: index.php?errorCode=1');
die();
}

// 加到資料庫
$sql = sprintf(
"INSERT INTO comments(`nickname`, `content`) VALUES ('%s', '%s')",
$nickname,
$content
);

try {
$result = $conn->query($sql);
header('Location: index.php');
} catch (Exception $e) {
echo '執行失敗:' . $e->getMessage() . '<br>';
echo '錯誤代碼:' . $conn->errno;
}
?>

簡單來說,當 request 送出去時,handle_add_comments.php 會從 cookie 的內容找出對應的 user,取得使用者資訊後再把內容寫到資料庫裡。也就是說 cookie 寫誰就代表誰 的意思,因此最後留言的才會是無慘而不是煉獄。

解決辦法

前面這種「把資訊都存在瀏覽器的 Cookie」的做法其實叫做「Cookie-based session」,但現在要換個思維,我們可以改成「把資訊存在 Server 端」來處理,這種作法叫「Session Identifier」。

意思是說,要存在 cookie 的東西不是資訊本身,而是一個類似「通行證」的東西。就好像你去好市多購物要檢查會員卡一樣,「會員卡」就是要用來放在 cookie 裡面給 Server 識別的東西。

話說回來,剛剛說把要儲存的資訊放在 Server 端,但實際上是指哪裡?

當然就是 資料庫 囉!

我們可以新開一個叫做 tokens 的資料庫,專門放每個 token 對應到哪個 user,像這樣:

token

現在還沒存任何東西所以欄位都是空的。

總之呢,登入這一段要做的事情很簡單:

  1. 當登入成功後,產生一個 token
  2. 把 token 寫到 cookie,並且把 token 對應到的 user 寫到資料庫中
1
2
3
4
5
6
7
8
9
10
11
// 先寫好產生 token 的 function
<?php
function generateToken() {
$s = '';
for ($i=0; $i<15; $i++) {
// A ~ Z
$s .= chr(rand(65,90));
}
return $s;
}
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* handle_login.php */
<?php
require_once('./conn.php');
require_once('./utils.php');
// POST 過來的帳號密碼
$username = $_POST['username'];
$password = $_POST['password'];

// 檢查帳號密碼
$sql = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = $conn->query($sql);

/*
帳號密碼正確的話:
1. 產生 Token
2. 把 Token 跟對應的 username 存到資料庫
3. 把 Token 存到 cookie 中
*/
if ($result->num_rows > 0) {
// 產生 Token
$token = generateToken();
// 下 query
$sql = "INSERT INTO tokens(`token`, username) VALUE('$token', '$username')";
$conn->query($sql);
// cookie 過期時間
$expire = time() + 3600 * 24 * 30;
// 設定 cookie
setcookie('token', $token, $expire);
// 登入成功,導回首頁
header('Location: ./index.php');
} else {
// 登入失敗
header('Location: ./login.php?errorCode=2');
}
?>

接下來的 index.php 的流程也差不多,只是稍微多幾個步驟:

  1. 根據 cookie 中的 token 去 tokens(table) 找到對應的 username
  2. 拿 username 去 users(table) 找到對應的 user
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* index.php */

<?php
require_once("./conn.php");
require_once("./utils.php");
// 沒登入的話就是 null
$username = Null;
// 檢查 cookie 中的 token
if (!empty($_COOKIE['token'])) {
$token = $_COOKIE['token'];
// 用 token 去拿 user 資料
$user = getUserFromToken($token);
$username = $user['username'];
$nickname = $user['nickname'];
}
?>

一樣附上 getUserFromToken($token) 的程式邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* utils.php */
<?php
require_once('./conn.php');
function getUserFromToken($token) {
// 存取全域變數要記得用 global
global $conn;
// 先從 tokens 中找到對應的 username
$sql = "SELECT username FROM tokens WHERE token='$token'";
$result = $conn->query($sql);
// 代表沒有找到對應的 user,回傳 Failed
if ($result->num_rows === 0) {
return 'Failed';
}
// 成功拿到 tokens 中的 username
$row = $result->fetch_assoc();
$username = $row['username'];
// 下第二個 query
$sql = "SELECT * FROM users WHERE username='$username'";
$result = $conn->query($sql);
// 這邊就是 user 的資料了,傳回去
return $result->fetch_assoc();
}
?>

最後一樣就完成登入機制了,但跟剛剛不同的是現在沒辦法再竄改 cookie 來假冒別人身分:

success

一樣附上「新增留言」的程式邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php
require_once('./conn.php');
require_once('./utils.php');
/*
這邊一樣是存取 cookie 值,
但是當 token 不正確時,getUserFromToken($token) 會回傳 Failed,
所以下面的 $nickname = $user['nickname'] 就會噴 Fatal error,
你可以把這邊改成導回 index.php?errorCode=2 顯示錯誤訊息之類的。
總之,這邊的重點是要說明沒辦法再竄改 cookie 了而已。
*/
$token = $_COOKIE['token'];
$user = getUserFromToken($token);
$nickname = $user['nickname'];
$content = $_POST['content'];

// 檢查留言內容
if (empty($content)) {
header('Location: index.php?errorCode=1');
die();
}

// 加到資料庫
$sql = sprintf(
"INSERT INTO comments(`nickname`, `content`) VALUES ('%s', '%s')",
$nickname,
$content
);

try {
$result = $conn->query($sql);
header('Location: index.php');
} catch (Exception $e) {
echo '執行失敗:' . $e->getMessage() . '<br>';
echo '錯誤代碼:' . $conn->errno;
}
?>

以上就是解決 cookie 被竄改的應對方法,以及 Session Identifier 的概念,把資料存在 Server,Cookie 只存 Token(或叫 Session ID)。

最後補充一下,登出的時候不要忘了把資料庫中的 token 給清掉:

1
2
3
4
5
6
7
8
9
10
11
/* handle_logout.php */
<?php
require_once('./conn.php');
$token = $_COOKIE['token'];
// 先刪除資料庫中的內容
$sql = "DELETE FROM tokens WHERE token='$token'";
$conn->query($sql);
// 再清除 cookie
setcookie('token', '', time() - 3600);
header('Location: ./index.php');
?>

後記

其實 PHP 裡面就有內建的 session 機制,用法可以參考這篇:PHP 內建的 session 方法

雖然內建方法用起來簡單,而且也比較穩定,但自己手工實作一遍確實會對「Session Identifier」的概念有更深的理解,所以才會想寫篇文章來做紀錄。

Encode、Encrypt 跟 Hash 的差別 利用 Cookie 實作登入機制
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×