iT邦幫忙

2024 iThome 鐵人賽

DAY 7
1
Security

資安這條路:系統化學習網站安全與網站滲透測試系列 第 7

資安這條路:Day 7 從登入註冊功能理解 Cookie、Session

  • 分享至 

  • xImage
  •  

今日我們將延續昨天的內容,從網站開發中常見的登入註冊功能切入,引導大家逐步認識 Cookie、Session 等重要概念。

透過思考問題引導大家學習

一、 從使用者體驗出發:設計登入註冊功能

  • 引導問題
    • 請大家回想自己註冊和登入網站的經驗,並思考:
      • 為什麼網站需要使用者註冊和登入?
      • 登入註冊功能有哪些必要的元素?
      • 網站是如何記住已登入的使用者?

二、 深入理解 Cookie 與 Session

  • 模擬情境:
    • 模擬使用者登入網站後,瀏覽器和伺服器之間的互動過程,例如使用開發者工具觀察網路請求和回應。
    • 伺服器是如何辨識不同使用者的請求?
    • 使用者資訊儲存在哪裡?

知識內容

一、 從使用者體驗出發:設計登入註冊功能

1. 為什麼網站需要使用者註冊和登入?

網站需要使用者註冊和登入主要有以下幾個原因:

  • 個人體驗:讓網站能夠為每個使用者提供量身客製化內容和服務。
  • 資料安全:保護使用者的個人資訊和隱私。
  • 功能限制:某些功能或服務可能只對註冊使用者開放。
  • 使用者追蹤:便於網站分析使用者行為,改善服務品質。
  • 社群互動:讓使用者能夠參與討論、評論等社群活動。
  • 商業目的:收集使用者資料用於精準行銷或改善產品。

你還有想到哪一些呢?可以在底下留言討論。

2. 登入註冊功能有哪些必要的元素?

一個基本的登入註冊功能通常包含以下必要元素:

  • 註冊
    • 使用者名稱或電子郵件地址
    • 密碼(通常輸入兩次確認輸入)
    • 多因素驗證機制(如信箱驗證、手機驗證碼等)
    • 使用者協議和隱私政策同意書(個資條款)
  • 登入
    • 使用者名稱或電子郵件地址輸入欄
    • 密碼輸入欄
    • 忘記密碼功能
    • 登入按鈕

3. 網站是如何記住已登入的使用者?

你有想過為什麼在瀏覽器登入之後,再關閉瀏覽器,重新開啟卻仍然是已經登入的畫面呢?

其實在登入成功的當下,伺服器有在回應封包中送一個標頭,讓瀏覽器紀錄。叫做 Cookie 機制。

延伸思考:為什麼需要 Cookie 機制?

首先,複習一下,我們透過瀏覽器跟伺服器之間互動,並利用 HTTP 這個協定,也就是溝通的方法。

但是 HTTP 是一個無狀態(stateless)的協定。代表每次瀏覽器向伺服器發送請求時,伺服器都會將其視為一個全新的請求,不會保留之前請求的相關資訊。

假設每一次關掉瀏覽器,然後需要重新輸入帳號密碼,會造成很多人使用者體驗不好,因此需要一種方法來「記住」這個人曾經輸入過帳號密碼。

這就是為什麼我們需要 Cookie 和 Session。

Cookie 和 Sessio

Cookie 和 Sessio 工作流程

工作流程

Cookie 和 Session 是什麼?先透過工作流程來了解其運作:

  1. 使用者登入成功後,伺服器建立一個 Session,並生成一個唯一的 Session ID。
  2. 伺服器將 Session ID 透過 Cookie 發送給使用者的瀏覽器,這時候會儲存在使用者的瀏覽器中
  3. 使用者的瀏覽器在後續的每次請求中都會發送這個 Cookie。
  4. 伺服器收到請求後,從 Cookie 中取得 Session ID,然後使用這個 ID 找到對應的 Session 資訊。
  5. 透過這種方式,即使 HTTP 是無狀態的,伺服器也能夠"記住"使用者的狀態和資訊。

Cookie 簡介

Cookie 是儲存在使用者瀏覽器中的文字。當使用者瀏覽網站時,伺服器可以發送一些資料並儲存在 Cookie 中。之後,瀏覽器會在每次向該網站發送請求時自動加上這些 Cookie。

Cookie 的主要用途

  1. Session 管理(如使用者登入狀態、購物車等)
  2. 個性化設定(如使用者偏好、主題等)
  3. 追蹤使用者行為(如分析使用者如何使用網站)

Session 簡介

Session 是在伺服器端保存的一個資料結構,用來跟蹤使用者的狀態。每個 Session 都有一個唯一的標識符,通常稱為 Session ID。

Session 的主要用途

  1. 追蹤使用者狀態
  2. 不會被使用者任意修改內容
    • Session 資料儲存在伺服器端,使用者無法直接修改

雖然 Session 資料儲存在伺服器端,但攻擊者仍然可能透過一些手段,例如跨站請求偽造(CSRF)攻擊,來盜取或偽造使用者的 Session ID,進而操控使用者的 Session 資訊

Cookie 與 Session 差異比較

特性 Cookie Session
儲存位置 使用者端 (瀏覽器) 伺服器端
儲存資料類型 小型文字資料,例如使用者名稱、購物車資訊等 使用者狀態資訊,例如登入狀態、購物車內容、使用者偏好設定等。可以儲存物件或複雜資料結構。
安全性 安全性較低,因為 Cookie 儲存在使用者端,可能被竊取或偽造。 安全性較高,因為 Session 資料儲存在伺服器端,使用者無法直接訪問或修改。
存取方式 由瀏覽器自動處理,網站可以透過 JavaScript 或伺服器端程式碼設定和讀取 Cookie。 由伺服器端程式碼管理,通常透過 Session ID 來識別和操作每個使用者的 Session 資料。
生命週期 可以設定過期時間,過期後瀏覽器會自動刪除;也可以設定為 Session Cookie,在瀏覽器關閉時刪除。 通常在使用者關閉瀏覽器或一段時間不活動後過期。
大小限制 約 4KB 無嚴格限制,但過大的 Session 資料會影響伺服器效能。
對伺服器效能的影響 影響較小,因為 Cookie 資料儲存在使用者端,不會占用伺服器資源。 可能影響較大,因為 Session 資料儲存在伺服器端,如果同時有大量使用者線上,會占用伺服器記憶體和其他資源。
典型應用場景 * 儲存使用者偏好設定,例如語言、主題等。 * 追蹤使用者登入狀態。
* 記錄使用者行為,例如瀏覽歷史、購物車內容等。 * 儲存需要在不同頁面之間共用的使用者資訊。
* 防止跨站請求偽造 (CSRF) 攻擊,例如在表單中嵌入隱藏的 Session ID 或權杖,驗證請求來源是否合法。

注意事項:

  • 為了提升安全性,網站應盡量避免在 Cookie 中儲存敏感資訊,例如密碼、信用卡號等。
  • Session 機制依賴 Cookie 來儲存 Session ID,如果使用者禁用 Cookie,Session 將無法正常運作。

總結:

Cookie 和 Session 都是網站開發中常用的技術,它們各有優缺點,網站可以根據具體需求選擇合適的技術來儲存和管理使用者資訊和狀態。

實作

實作練習:

  1. 設定 PostgreSQL 資料庫: 使用 init.sql 初始化資料庫,並建立一個名為 myapp 的資料庫和一個包含使用者資訊的 users 表格。
  2. 實作使用者驗證: 新增 /login 和 /logout 路由,以及相關的控制器邏輯,讓使用者可以使用儲存在 PostgreSQL 資料庫中的憑證登入和登出。
  3. 新增使用者註冊: 加入 /register 路由和網頁表單,讓使用者可以註冊新帳戶。註冊的資訊會儲存在 PostgreSQL 資料庫中。
  4. 建立使用者儀表板: 新增 /dashboard 路由和網頁,僅供已登入的使用者存取。儀表板會顯示使用者的基本資訊。

設定 PostgreSQL 資料庫

  • 使用 Docker 與 Docker-compose 可以讓我們快速建立網站,上一篇文章我們利用操作 Linux 指令進行新增資料表,透過 curl 新增帳號。
  • 這次透過 docker-compose 先建立 init.sql 的檔案,當容器首次啟動時,會自動執行這個 SQL 腳本。

1. 修改 docker-compose.yml 文件

version: '3'
services:
  # ... 其他服務 ...
  postgres:
    image: postgres:13
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgresPassword
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
      - postgres_data:/var/lib/postgresql/data 

volumes:
  postgres_data:

2. 建立 init.sql 文件

-- 檢查 myapp 資料庫是否存在,如果不存在則建立資料庫
SELECT 'CREATE DATABASE myapp'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'myapp')\gexec

-- 連接到 myapp 資料庫
\c myapp

-- 建立表(如果不存在)
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 插入初始資料(如果表是空的)
INSERT INTO users (username, email, password)
SELECT 'testuser', 'testuser@example.com', 'password123' 
WHERE NOT EXISTS (SELECT 1 FROM users);

步驟解釋

  1. docker-compose.yml 文件中,我們新增了一個 volume 映射,將 init.sql 文件掛載到 PostgreSQL 容器的 /docker-entrypoint-initdb.d/ 目錄。這樣,當容器首次啟動時,會自動執行這個 SQL 腳本。
  2. init.sql 文件包含了建立 myapp 資料庫、users 表,以及插入一個測試使用者的 SQL 指令。
  3. 確認專案資料夾中有 init.sql 文件,內容如上所示。
  4. 執行 docker-compose up -d --build 重新建立與啟動服務。
  5. PostgreSQL 會自動執行 init.sql 腳本。

實作 Session 與 Cookie 機制

1. web/server.js

// 引入 session 套件
const session = require('express-session');

// 使用 session Middleware 來啟用 session 功能
app.use(session({
  secret: 'your_session_secret', // Hard-coded 密鑰,應該要更換
  // secret: process.env.SESSION_SECRET, // 從環境變數取得密鑰,比較安全
  resave: false, // 是否重新保存 session
  saveUninitialized: true, // 是否保存未初始化的 session
  cookie: { secure: false } // 在非 HTTPS 環境下也能使用 session
  // cookie: { secure: true } // 在 HTTPS 環境下使用 session
}));

實作

  1. 開啟首頁
  2. 開啟開發者工具
  3. 了解回應封包的 set-cookie

image

實作使用者驗證

實現使用者登入和登出功能。

1. 在 web/routes/authRoutes.js 中新增路由

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');

router.post('/login', authController.login);
router.post('/logout', authController.logout);

module.exports = router;

2. 在 web/controllers/authController.js 中實現控制器邏輯

const db = require('../db/postgres');

const authHandler = {
    async login(req, res) {
        const { username, password } = req.body;
        try {
            const result = await db.query('SELECT * FROM users WHERE username = $1', [username]);
            
            if (result.rows.length === 0) {
                return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
            }

            const user = result.rows[0];
            const isPasswordValid = password === user.password;
            
            if (!isPasswordValid) {
                return res.status(401).json({ message: '使用者不存在或密碼錯誤' });
            }
        
            req.session.userId = user.id;
            req.session.username = user.username;

            res.json({ message: '登入成功', user: { id: user.id, username: user.username, email: user.email } });
        } catch (error) {
            res.status(500).json({ error: error.message });
        }
    },

    async logout(req, res) {
        req.session.destroy(err => {
            if (err) {
                return res.status(500).json({ message: '登出失敗' });
            }
            res.json({ message: '登出成功' });
        });
    }
};

module.exports = authHandler;

3. 在 web/server.js 中新增路由和 session Middleware

const express = require('express');
const session = require('express-session');
const authRoutes = require('./routes/authRoutes');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.use(session({
  secret: 'your_session_secret',
  resave: false,
  saveUninitialized: true,
  cookie: { secure: false }
}));

app.use('/api/auth', authRoutes);

// ... 其他路由和Middleware ...

4. 建立 web/public/login.html

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>使用者登入</title>
    <style>
        /* ... 樣式省略 ... */
    </style>
</head>
<body>
    <div class="container">
        <h1>使用者登入</h1>
        <form id="loginForm">
            <label for="username">帳號</label>
            <input type="text" id="username" name="username" required>
            
            <label for="password">密碼</label>
            <input type="password" id="password" name="password" required>
            
            <button type="submit">登入</button>
        </form>
        <div id="errorMessage" class="error-message"></div>
    </div>

    <script>
        document.getElementById('loginForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            const errorMessage = document.getElementById('errorMessage');
            
            try {
                const response = await fetch('/api/auth/login', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ username, password }),
                });
                
                if (response.ok) {
                    const result = await response.json();
                    alert('登入成功!');
                    window.location.href = '/dashboard';
                } else {
                    const error = await response.json();
                    errorMessage.textContent = error.message || '登入失敗,請檢查您的使用者名和密碼';
                }
            } catch (error) {
                errorMessage.textContent = '發生錯誤,請稍後再試';
                console.error('Error:', error);
            }
        });
    </script>
</body>
</html>

步驟解釋

  1. 我們建立了 authRoutes.js 來處理登入和登出路由。
  2. authController.js 包含了登入和登出的邏輯。
  3. server.js 中,我們新增了 session Middleware和認證路由。
  4. login.html 提供了一個登入表單,並使用 JavaScript 來處理表單提交。

新增使用者註冊

現在,我們來實現使用者註冊功能。

1. 在 web/routes/authRoutes.js 中新增註冊路由

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');

router.post('/login', authController.login);
router.post('/logout', authController.logout);
router.post('/register', authController.register);  // 新增這行

module.exports = router;

2. 在 web/controllers/authController.js 中新增註冊邏輯

const db = require('../db/postgres');

const authHandler = {
    // ... 保留原有的 login 和 logout 方法 ...

    async register(req, res) {
        const { username, email, password } = req.body;
        try {
            const result = await db.query(
                'INSERT INTO users (username, email, password) VALUES ($1, $2, $3) RETURNING id, username, email',
                [username, email, password]
            );
            
            const newUser = result.rows[0];
            res.status(201).json({ message: '註冊成功', user: newUser });
        } catch (error) {
            if (error.code === '23505') { // unique_violation
                res.status(400).json({ message: '使用者名稱或電子郵件已存在' });
            } else {
                res.status(500).json({ error: error.message });
            }
        }
    }
};

module.exports = authHandler;

3. 建立 web/public/register.html

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>使用者註冊</title>
    <style>
        /* ... 樣式省略 ... */
    </style>
</head>
<body>
    <div class="container">
        <h1>使用者註冊</h1>
        <form id="registrationForm">
            <label for="username">帳號</label>
            <input type="text" id="username" name="username" required>
            
            <label for="email">電子郵件</label>
            <input type="email" id="email" name="email" required>
            
            <label for="password">密碼</label>
            <input type="password" id="password" name="password" required>
            
            <button type="submit">註冊</button>
        </form>
    </div>

    <script>
        document.getElementById('registrationForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            
            const username = document.getElementById('username').value;
            const email = document.getElementById('email').value;
            const password = document.getElementById('password').value;
            
            try {
                const response = await fetch('/api/auth/register', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify({ username, email, password }),
                });
                
                if (response.ok) {
                    const result = await response.json();
                    alert('註冊成功!');
                    window.location.href = '/login';
                } else {
                    const error = await response.json();
                    alert(`註冊失敗: ${error.message}`);
                }
            } catch (error) {
                alert('發生錯誤,請稍後再試');
                console.error('Error:', error);
            }
        });
    </script>
</body>
</html>

4. 在 web/server.js 中新增註冊頁面路由

app.get('/register', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'register.html'));
});

步驟解釋

  1. authRoutes.js 中新增了註冊路由。
  2. authController.js 中,我們實現了註冊邏輯,將新使用者資訊插入資料庫。
  3. 建立了 register.html,提供註冊表單和處理表單提交的 JavaScript 程式碼。
  4. server.js 中新增了註冊頁面的路由。

實作

  1. 進入 http://nodelab.feifei.tw/register
  2. 開啟開發人員工具
  3. 觀察請求封包與回應封包
  4. 撰寫可能的資安問題(hint: 回傳內容包含OOO敏感資訊)

image

建立使用者儀表板

1. 在 web/server.js 中新增儀表板路由

const express = require('express');
const path = require('path');

// ... 其他導入和Middleware ...

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.get('/dashboard', (req, res) => {
  if (req.session.userId) {
    res.render('dashboard', {
      username: req.session.username,
    });
  } else {
    res.redirect('/login');
  }
});

2. 建立 web/views/dashboard.ejs

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>使用者儀表板</title>
    <style>
        /* ... 樣式省略 ... */
    </style>
</head>
<body>
    <header>
        <h1>使用者儀表板</h1>
        <div class="user-info">
            歡迎, <span id="username"><%= username %></span>!
            <button id="logoutBtn" class="logout-btn">登出</button>
        </div>
    

步驟

  1. 新增 dashboard
  2. 了解 ejs 樣板語言與使用

實作1

  1. 進入 http://nodelab.feifei.tw/login 登入
  2. 觀察 login 與 dashboard 封包

image

實作2

  1. 進行註冊帳號 <h1>test<h1>
  2. 進行登入
  3. 查看 dashboard
  4. 發現 html 沒有被觸發

image

思考: 為什麼 html 沒有被觸發

總結

本文深入探討了網站登入註冊功能的實現,涵蓋了 Cookie、Session 的概念及其在使用者身份驗證中的應用。主要內容包括:

  • Cookie 和 Session 的基本概念、工作原理及區別
  • 使用 PostgreSQL 資料庫儲存使用者資訊
  • 實現使用者註冊、登入、登出功能
  • 建立使用者儀表板頁面
  • 使用 EJS 模板引擎渲染動態內容

實作整理

  • 設置 PostgreSQL 資料庫並初始化使用者表
  • 實現 Session 和 Cookie 機制
  • 建立登入、註冊和儀表板頁面
  • 實現使用者認證邏輯(登入、註冊、登出)
  • 使用 EJS 模板渲染儀表板頁面

思考題

問題: 為什麼在儀表板頁面中,使用 <h1>test<h1> 作為使用者名稱註冊並登入後,HTML 標籤沒有被觸發?

答案: 這是因為 EJS 模板引擎在渲染變數時會自動進行 HTML 轉義,將特殊字符(如 <>)轉換為對應的 HTML 實體(&lt;&gt;),從而防止 XSS 攻擊。這是一種安全機制,確保使用者輸入的內容不會被誤解為 HTML 執行。

選擇題

  1. Cookie 儲存在哪裡?

    • a) 伺服器
    • b) 瀏覽器
    • c) 資料庫
    • d) 快取
    • 答案: b
  2. Session 資料通常儲存在哪裡?

    • a) 客戶端
    • b) 瀏覽器
    • c) 伺服器
    • d) Cookie
    • 答案: c
  3. 哪種方法不適合用來儲存敏感資訊?

    • a) 資料庫加密
    • b) Session
    • c) Cookie
    • d) 伺服器檔案系統
    • 答案: c
  4. EJS 是什麼?

    • a) 前端框架
    • b) 模板引擎
    • c) 資料庫
    • d) API
    • 答案: b
  5. 以下哪項不是實現使用者認證的常見方法?

    • a) Session
    • b) JWT
    • c) OAuth
    • d) CSS
    • 答案: d

資安相關的研究方向

  • 密碼儲存:應使用強雜湊演算法(如 bcrypt)對密碼進行雜湊處理後再儲存,避免明文儲存。
    • 要修改哪裡,才可以進行雜湊處理?
  • 輸入驗證:對所有使用者輸入進行嚴格的驗證和清理,防止 SQL 注入等攻擊。
    • 哪裡需要進行輸入驗證?
  • ** Session 管理**:實現適當的 Session 超時機制,並在使用者登出時正確銷毀 Session 。
    • 哪一個地方進行 Session 管理?
  • 錯誤處理:避免在正式環境中暴露詳細的錯誤資訊,防止資訊洩露。
    • 如何關閉詳細錯誤訊息
    • 如何記錄錯誤 log
  • 安全標頭:設定適當的 HTTP 安全標頭,如 Content-Security-Policy、X-Frame-Options 等。
    • 這些標頭是什麼?
    • 如何設定
  • Rate Limiting:實現請求頻率限制,防止暴力破解和 DoS 攻擊。
    • 如何進行暴力破解攻擊

上一篇
資安這條路:Day6 資料庫安全與使用者管理系統實作、API 安全
下一篇
資安這條路:Day 8 從 Cookie HTTPOnly 了解 Session Hijacking
系列文
資安這條路:系統化學習網站安全與網站滲透測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言