本文為《PHP經(jīng)典實(shí)例》閱讀筆記
前言
隨著web應(yīng)用日漸成熟,“有狀態(tài)性”成為一個(gè)常見(jiàn)的需求锈嫩,有狀態(tài)應(yīng)用已經(jīng)相當(dāng)普及酪夷,甚至被認(rèn)為是理所當(dāng)然的。有狀態(tài)應(yīng)用是指:訪問(wèn)者瀏覽網(wǎng)站時(shí)导梆,應(yīng)用能跟蹤記錄這個(gè)訪問(wèn)者的信息。雖然http被設(shè)計(jì)為無(wú)狀態(tài)協(xié)議,不過(guò)PHP提供了一組方便的會(huì)話管理函數(shù)问潭,使實(shí)現(xiàn)有狀態(tài)應(yīng)用更方便簡(jiǎn)單猿诸,后文將介紹開(kāi)發(fā)有狀態(tài)應(yīng)用時(shí)要謹(jǐn)記的一些優(yōu)秀實(shí)踐做法。
使用會(huì)話跟蹤
我們可以使用會(huì)話模塊來(lái)跟蹤用戶狡忙,如下面的一個(gè)例子:
session_start();
if (! isset($_SESSION['visit'])){
$_SESSION['visit'] = 0;
}
$_SESSION['visit']++;
echo 'You have visited here '.$_SESSION['visit'].' times.';
會(huì)話模塊通過(guò)向用戶發(fā)送cookie來(lái)跟蹤用戶梳虽,cookie中包含隨機(jī)生成的session id,且cookie名為PHPSESSID
灾茁。如果用戶不接受cookie窜觉,那么會(huì)在URL后加上?PHPSESSID=xxxx(id)
北专,使之能傳遞到下一個(gè)頁(yè)面禀挫。明顯這樣的URL并不安全,比如一個(gè)用戶復(fù)制該URL并發(fā)送給其他人拓颓,那么無(wú)意間其他人便會(huì)假冒成該用戶訪問(wèn)網(wǎng)站语婴,因此默認(rèn)會(huì)禁止這種行為。要啟用URL中傳遞session id的功能驶睦,可以在開(kāi)始會(huì)話前使用ini_set('session.use_trans_sid',true)
砰左。
防止會(huì)話劫持
為確保攻擊者不能訪問(wèn)另一用戶的會(huì)話,我們可以規(guī)定只允許通過(guò)cookie傳遞session id场航,并生成另外一個(gè)會(huì)話token通過(guò)URL傳遞缠导。只有包含一個(gè)合法session id和合法token才可以訪問(wèn)會(huì)話,如下面部分示例代碼:
ini_set('session.use_only_cookies', true);
//指定是否在客戶端僅僅使用 cookie 來(lái)存放會(huì)話 ID溉痢,啟用此設(shè)定可以防止有關(guān)通過(guò) URL 傳遞會(huì)話 ID 的攻擊僻造。
session_start();
$salt = 'YourSpecialValueHere';
$tokenstr = strval(date('W')).$salt;
$token = md5($tokenstr);
if (!isset($_REQUEST['token']) || $_REQUEST['token'] != $token){
// 提示登錄
echo "Please login";
exit;
}
$_SESSION['token'] = $token;
output_add_rewrite_var('token', $token);
該例通過(guò)將當(dāng)前周數(shù)strval(date('W'))
與變量$salt
連接為一個(gè)字符串,創(chuàng)建一個(gè)自動(dòng)移位的token孩饼,保證token是不固定的且在一段時(shí)間內(nèi)是合法的髓削。
然后檢查請(qǐng)求中的token【$_REQUEST具有$_POST和$_GET的功能,但相對(duì)來(lái)說(shuō)會(huì)比較慢
】捣辆,如果未找到則提示重新登錄蔬螟,找到則將它添加到生成的鏈接【例如當(dāng)前頁(yè)面<a>標(biāo)簽的鏈接后作為get的參數(shù)
】或者表單中【以input隱藏域形式
】,以保證下一次請(qǐng)求能順利進(jìn)行汽畴。用output_add_rewrite_var()
來(lái)實(shí)現(xiàn)上述功能。
防止會(huì)話固定攻擊
為確保應(yīng)用不會(huì)受到會(huì)話固定攻擊(攻擊者強(qiáng)制用戶使用一個(gè)預(yù)定義的會(huì)話ID)耸序,我們應(yīng)使用會(huì)話cookie但會(huì)話標(biāo)識(shí)符不會(huì)追加到url中忍些,同時(shí)頻繁生成新的會(huì)話ID。
ini_set('session.use_only_cookies', true);
session_start();
if (!isset($_SESSION['generated'])
|| $_SESSION['generated'] < (time() - 30)) {
session_regenerate_id();
$_SESSION['generated'] = time();
}
該例首先設(shè)置會(huì)話行為坎怪,即只能用cookie存儲(chǔ)session id罢坝,確保PHP不會(huì)注意攻擊者放在URL中的session id。
一旦會(huì)話開(kāi)始,設(shè)置一個(gè)值來(lái)記錄生成session id的最后時(shí)間嘁酿,定期生成一個(gè)新的session id隙券,該例所定時(shí)間為30秒,就能大大降低攻擊者得到合法session id的幾率闹司。
這兩種方法結(jié)合娱仔,基本可以消除會(huì)話固定攻擊的風(fēng)險(xiǎn)。攻擊者很難得到一個(gè)合法的session id游桩,因?yàn)閕d會(huì)頻繁變化牲迫,另外由于session id只能在cookie中傳遞,因此基于url的攻擊是不可能的借卧。
在數(shù)據(jù)庫(kù)中存儲(chǔ)會(huì)話
我們可能希望在數(shù)據(jù)庫(kù)中存儲(chǔ)會(huì)話數(shù)據(jù)而不是在文件中盹憎,這時(shí)如果多個(gè)服務(wù)器可以訪問(wèn)同一個(gè)數(shù)據(jù)庫(kù),那么會(huì)話數(shù)據(jù)就會(huì)鏡像到所有web服務(wù)器铐刘。具體方法便是通過(guò)向session_set_save_handler()
提供一個(gè)實(shí)現(xiàn)SessionHandlerInterface
接口的實(shí)例陪每,來(lái)注冊(cè)自定義會(huì)話存儲(chǔ)函數(shù)(在PHP 5.4以后的版本才能這樣用)。首先我們實(shí)現(xiàn)接口如下镰吵,其文件名為db.php檩禾,它使用PDO將session 數(shù)據(jù)存儲(chǔ)在一個(gè)數(shù)據(jù)庫(kù)表中:
class DBHandler implements SessionHandlerInterface {
protected $dbh;
/**
* open 回調(diào)函數(shù)類(lèi)似于類(lèi)的構(gòu)造函數(shù),在會(huì)話打開(kāi)的時(shí)候會(huì)被調(diào)用捡遍。
* 這是自動(dòng)開(kāi)始會(huì)話或者通過(guò)調(diào)用session_start() 手動(dòng)開(kāi)始會(huì)話 之后第一個(gè)被調(diào)用的回調(diào)函數(shù)锌订。
* 此回調(diào)函數(shù)操作成功返回 TRUE,反之返回 FALSE画株。
*/
public function open($save_path, $name) {
try {
$this->connect($save_path, $name);
return true;
} catch (PDOException $e) {
return false;
}
}
/**
* close 回調(diào)函數(shù)類(lèi)似于類(lèi)的析構(gòu)函數(shù)辆飘。在 write 回調(diào)函數(shù)調(diào)用之后調(diào)用。
* 當(dāng)調(diào)用 session_write_close() 函數(shù)之后谓传,也會(huì)調(diào)用 close 回調(diào)函數(shù)蜈项。
* 此回調(diào)函數(shù)操作成功返回 TRUE,反之返回 FALSE续挟。
*/
public function close() {
return true;
}
/**
* 銷(xiāo)毀session時(shí)會(huì)調(diào)用
* 當(dāng)調(diào)用session_destroy()函數(shù)紧卒,或者調(diào)用session_regenerate_id()函數(shù)并且設(shè)置 destroy 參數(shù)為 TRUE 時(shí),會(huì)調(diào)用此回調(diào)函數(shù)诗祸。
* 此回調(diào)函數(shù)操作成功返回 TRUE跑芳,反之返回 FALSE。
*/
public function destroy($session_id) {
$sth = $this->dbh->prepare("DELETE FROM sessions WHERE session_id = ?");
$sth->execute(array($session_id));
return true;
}
/**
* 讀取session時(shí)調(diào)用
* 如果會(huì)話中有數(shù)據(jù)直颅,read 回調(diào)函數(shù)必須返回將會(huì)話數(shù)據(jù)編碼(序列化)后的字符串博个。如果會(huì)話中沒(méi)有數(shù)據(jù),read 回調(diào)函數(shù)返回空字符串功偿。
*
* 在自動(dòng)開(kāi)始會(huì)話或者通過(guò)調(diào)用 session_start() 函數(shù)手動(dòng)開(kāi)始會(huì)話之后盆佣,PHP內(nèi)部調(diào)用 read 回調(diào)函數(shù)來(lái)獲取會(huì)話數(shù)據(jù)。在調(diào)用 read 之前,PHP會(huì)調(diào)用 open 回調(diào)函數(shù)共耍。
*
* read 回調(diào)返回的序列化之后的字符串格式必須與 write 回調(diào)函數(shù)保存數(shù)據(jù)時(shí)的格式完全一致虑灰。 PHP 會(huì)自動(dòng)反序列化返回的字符串并填充 $_SESSION 超級(jí)全局變量。 雖然數(shù)據(jù)看起來(lái)和 serialize() 函數(shù)很相似痹兜, 但是需要提醒的是穆咐,它們是不同的。
*/
public function read($session_id) {
$sth = $this->dbh->prepare("SELECT session_data FROM sessions WHERE session_id = ?");
$sth->execute(array($session_id));
$rows = $sth->fetchAll(PDO::FETCH_NUM);
if (count($rows) == 0) {
return '';
} else {
return $rows[0][0];
}
}
/**
* 向數(shù)據(jù)庫(kù)中寫(xiě)入數(shù)據(jù)
*/
public function write($session_id, $session_data) {
$now = time();
$sth = $this->dbh->prepare("UPDATE sessions SET session_data = ?,last_update = ? WHERE session_id = ?");
$sth->execute(array($session_data, $now, $session_id));
if ($sth->rowCount() == 0) {
$sth2 = $this->dbh->prepare('INSERT INTO sessions (session_id,session_data, last_update) VALUES (?,?,?)');
$sth2->execute(array($session_id, $session_data, $now));
}
}
/**
* 建表
*/
public function createTable($save_path, $name, $connect = true) {
if ($connect) {
$this->connect($save_path, $name);
}
$sql=<<<_SQL_
CREATE TABLE sessions (
session_id VARCHAR(64) NOT NULL,
session_data MEDIUMTEXT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (session_id)
)
_SQL_;
$this->dbh->exec($sql);
}
/**
* 連接數(shù)據(jù)庫(kù)
*/
protected function connect($save_path) {
/* 在DSN中查找作為“查詢字符串”參數(shù)的用戶和密碼 */
$parts = parse_url($save_path);
if (isset($parts['query'])) {
parse_str($parts['query'], $query);
$user = isset($query['user']) ? $query['user'] : null;
$password = isset($query['password']) ? $query['password'] : null;
$dsn = $parts['scheme'] . ':';
if (isset($parts['host'])) {
$dsn .= '//' . $parts['host'];
}
$dsn .= $parts['path'];
$this->dbh = new PDO($dsn, $user, $password);
} else {
$this->dbh = new PDO($save_path);
}
$this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 創(chuàng)建會(huì)話表的方法(使用異常處理)
try {
$this->dbh->query('SELECT 1 FROM sessions LIMIT 1');
} catch (Exception $e) {
$this->createTable($save_path, NULL, false);
}
}
}
接下來(lái)演示如何將該類(lèi)與session_set_save_handler()
結(jié)合佃蚜,實(shí)現(xiàn)在數(shù)據(jù)庫(kù)中存儲(chǔ)session數(shù)據(jù)庸娱。
include __DIR__ . '/db.php';
ini_set('session.save_path', 'sqlite:/tmp/sessions.db');
session_set_save_handler(new DBHandler);
session_start();
if (! isset($_SESSION['visits'])) {
$_SESSION['visits'] = 0;
}
$_SESSION['visits']++;
print 'You have visited here '.$_SESSION['visits'].' times.';
這個(gè)代碼塊假設(shè)與db.php在同一目錄中,一旦將session.save_path
設(shè)置為指定的PDO DSN谐算,只需要session_set_save_handler(new DBHandler);
就可以將PHP與這個(gè)程序關(guān)聯(lián)起來(lái)熟尉。在此之后,使用會(huì)話的代碼與使用PHP默認(rèn)處理程序的代碼是一樣的洲脂。
關(guān)于以上的函數(shù)講的并不全面斤儿,推薦到 http://php.net/ 去查看詳情。