跳至正文

🔐 什麼是 OAuth 2.0?

📖 定義

OAuth 2.0 是一種開放標準協定,允許使用者在不共享密碼的情況下,授權其他網站或應用程式存取其資訊。「使用 Google 登入」、「使用 GitHub 登入」等社交登入就是典型的例子。它專注於授權(Authorization)而非認證(Authentication),提供安全且便利的使用者體驗。

🎯 用類比理解

飯店鑰匙卡

傳統方式 = 飯店萬能鑰匙
飯店主人: "給你萬能鑰匙"
你: "這可以打開所有房間! 😱"
"這就像告訴別人我的密碼"

OAuth 2.0 = 飯店鑰匙卡
飯店主人: "這張鑰匙卡..."
✅ 只能打開你的房間
✅ 退房時自動失效
✅ 遺失了萬能鑰匙還是安全的
✅ 需要時隨時可以作廢

你: "完美!"

委託授權書

情境: 委託代理人處理銀行業務

❌ 密碼共享方式
你: "我的銀行密碼是 1234"
代理人: "我可以在銀行做任何事!"
風險: 提款、刪除帳戶等所有權限

✅ OAuth 方式
你: "去銀行填寫委託書"
銀行: "要授予什麼權限?"
你: "只能查詢餘額"
銀行: "期限呢?"
你: "只要一週"

代理人:
✅ 只能查詢餘額
✅ 一週後自動失效
✅ 不知道你的密碼
✅ 隨時可以撤銷權限

⚙️ 工作原理

1. OAuth 2.0 主要概念

┌─────────────────────────────────────────┐
│ OAuth 2.0 四種角色 │
└─────────────────────────────────────────┘

1. Resource Owner (資源擁有者)
└─ 使用者 (你)

2. Client (用戶端)
└─ 想要使用服務的應用程式
└─ 例: 你建立的網站

3. Authorization Server (授權伺服器)
└─ 授予權限的伺服器
└─ 例: Google、GitHub、Naver

4. Resource Server (資源伺服器)
└─ 擁有使用者資訊的伺服器
└─ 例: Google API、GitHub API

2. OAuth 2.0 認證流程 (Authorization Code)

┌──────────┐                                 ┌──────────┐
│ 使用者 │ │ 用戶端 │
│ (你) │ │ (網頁) │
└─────┬────┘ └────┬─────┘
│ │
│ 1. 點擊「使用 Google 登入」 │
│──────────────────────────────────────────>│
│ │
│ 2. 重新導向到 Google 登入頁面 │
│<──────────────────────────────────────────│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ 3. 登入 │ (授權伺服器) │ │
│────────>│ │ │
│ │ │ │
│ 4. 請求權限 │
│ 「這個應用程式想要查看你的 │
│ 電子郵件和個人資料」 │
│<────────│ │ │
│ │ │ │
│ 5. 核准 │ │ │
│────────>│ │ │
│ │ │ │
│ │ 6. 發行 Authorization Code │
│ │ (一次性代碼) │
│<────────│ │ │
│ └──────────────────┘ │
│ │
│ 7. 傳遞 Authorization Code │
│──────────────────────────────────────────>│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ │ (授權伺服器) │ 8. Code 和 │
│ │ │<─── Client │
│ │ │ Secret │
│ │ │ 交換 │
│ │ │ │
│ │ 9. Access Token │ ────────────>│
│ │ 發行 │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Google API │ │
│ │ (資源伺服器) │ 10. 用 Token│
│ │ │<──── 請求使用│
│ │ │ 者資訊 │
│ │ │ │
│ │ 11. 使用者資訊 │ ────────────>│
│ │ 回應 │ │
│ └──────────────────┘ │
│ │
│ 12. 登入完成! │
│<──────────────────────────────────────────│
│ │

重點:
- 使用者密碼只輸入給 Google
- 網頁應用程式絕對不會知道密碼
- 只用 Access Token 存取有權限的資訊

3. OAuth 2.0 授權類型

========== 1. Authorization Code (最安全) ==========
用途: 伺服器端網頁應用程式
優點: 最安全 (使用 Client Secret)
範例: 「使用 Google 登入」

流程:
1. 使用者 → Google 登入
2. Google → 發行 Authorization Code
3. 伺服器 → Code + Secret 交換 Access Token
4. 伺服器 → 用 Token 呼叫 API

========== 2. Implicit (簡單但較不安全) ==========
用途: 純瀏覽器應用程式 (SPA)
優點: 簡單 (不需伺服器)
缺點: 安全性弱 (Token 暴露在 URL)
範例: 舊式 SPA

流程:
1. 使用者 → Google 登入
2. Google → 直接發行 Access Token (在 URL)
3. 瀏覽器 → 用 Token 呼叫 API
⚠️ 目前不建議使用

========== 3. Resource Owner Password (遺留) ==========
用途: 僅限可信任的應用程式
優點: 簡單
缺點: 應用程式會知道使用者密碼
範例: 自家行動應用程式

流程:
1. 應用程式 → 接收使用者 ID/密碼
2. 應用程式 → 傳遞給授權伺服器
3. 授權伺服器 → 發行 Access Token
⚠️ 違反 OAuth 理念,不建議使用

========== 4. Client Credentials ==========
用途: 伺服器間通訊 (M2M)
優點: 不需使用者認證
範例: 後端服務間通訊

流程:
1. 伺服器 → 傳遞 Client ID + Secret
2. 授權伺服器 → 發行 Access Token
3. 伺服器 → 用 Token 呼叫 API

========== 5. PKCE (Proof Key for Code Exchange) ==========
用途: 行動應用程式、SPA (目前推薦)
優點: Authorization Code + 額外安全性
範例: 現代行動/SPA 應用程式

流程:
1. 應用程式 → 產生 code_verifier (隨機字串)
2. 應用程式 → 產生 code_challenge (verifier 雜湊)
3. 應用程式 → 帶著 challenge 請求 Authorization Code
4. Google → 發行 Code
5. 應用程式 → Code + verifier 交換 Token
6. Google → 驗證 verifier 後發行 Token

💡 實際範例

Google OAuth 2.0 登入 (Node.js)

// ========== 1. Google Cloud Console 設定 ==========
/*
1. 造訪 https://console.cloud.google.com
2. 建立專案
3. 「API 和服務」 → 「憑證」
4. 建立「OAuth 2.0 用戶端 ID」
5. 新增已授權的重新導向 URI:
http://localhost:3000/auth/google/callback
6. 取得 Client ID 和 Client Secret
*/

// ========== 2. 伺服器實作 ==========
const express = require('express');
const axios = require('axios');
const session = require('express-session');

const app = express();

// 工作階段設定
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: true
}));

// Google OAuth 設定
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/google/callback';

// ========== 步驟 1: 開始登入 ==========
app.get('/auth/google', (req, res) => {
// 產生 Google 認證 URL
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';

const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code', // 請求 Authorization Code
scope: 'openid email profile', // 請求的權限
access_type: 'offline', // 取得 Refresh Token
prompt: 'consent' // 總是顯示同意畫面
});

// 重新導向到 Google 登入頁面
res.redirect(`${authUrl}?${params}`);
});

// ========== 步驟 2: 處理回呼 ==========
app.get('/auth/google/callback', async (req, res) => {
const { code, error } = req.query;

// 使用者拒絕的情況
if (error) {
return res.status(400).send(`認證失敗: ${error}`);
}

try {
// 將 Authorization Code 交換為 Access Token
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
{
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
}
);

const {
access_token,
refresh_token,
expires_in,
token_type
} = tokenResponse.data;

console.log('Access Token:', access_token);
console.log('Expires in:', expires_in, 'seconds');

// 用 Access Token 取得使用者資訊
const userResponse = await axios.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
headers: {
Authorization: `Bearer ${access_token}`
}
}
);

const user = userResponse.data;
/*
{
id: "123456789",
email: "user@gmail.com",
verified_email: true,
name: "王小明",
picture: "https://lh3.googleusercontent.com/...",
locale: "zh-TW"
}
*/

// 將使用者資訊儲存到工作階段
req.session.user = {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
accessToken: access_token,
refreshToken: refresh_token
};

// 儲存使用者到資料庫
await saveOrUpdateUser(user);

// 重新導向到首頁
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth 錯誤:', error.response?.data || error.message);
res.status(500).send('認證處理時發生錯誤');
}
});

// ========== 檢查使用者資訊 ==========
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/');
}

res.send(`
<h1>你好, ${req.session.user.name}!</h1>
<img src="${req.session.user.picture}" alt="個人資料" />
<p>電子郵件: ${req.session.user.email}</p>
<a href="/logout">登出</a>
`);
});

// ========== 登出 ==========
app.get('/logout', async (req, res) => {
// 撤銷 Google Access Token (選用)
const accessToken = req.session.user?.accessToken;
if (accessToken) {
try {
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);
} catch (error) {
console.error('Token 撤銷失敗:', error.message);
}
}

// 刪除工作階段
req.session.destroy();
res.redirect('/');
});

// ========== 首頁 ==========
app.get('/', (req, res) => {
res.send(`
<h1>OAuth 2.0 示範</h1>
<a href="/auth/google">
<button>使用 Google 登入</button>
</a>
`);
});

app.listen(3000, () => {
console.log('伺服器執行中: http://localhost:3000');
});

使用 Passport.js 簡化實作

// ========== 使用 Passport.js ==========
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// Passport 設定
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
// 處理使用者資訊
try {
// 從資料庫尋找或建立使用者
let user = await User.findOne({ googleId: profile.id });

if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
});
}

done(null, user);
} catch (error) {
done(error, null);
}
}
));

// 工作階段序列化
passport.serializeUser((user, done) => {
done(null, user.id);
});

passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});

// Express 設定
app.use(passport.initialize());
app.use(passport.session());

// ========== 路由 ==========

// 開始 Google 登入
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);

// Google 回呼
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login',
successRedirect: '/dashboard'
})
);

// 登出
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) console.error(err);
res.redirect('/');
});
});

// 認證中介軟體
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}

// 受保護的路由
app.get('/dashboard', isAuthenticated, (req, res) => {
res.send(`你好, ${req.user.name}!`);
});

GitHub OAuth 2.0

// ========== GitHub OAuth 設定 ==========
/*
1. 造訪 https://github.com/settings/developers
2. 點擊「New OAuth App」
3. 輸入資訊:
- Application name: My App
- Homepage URL: http://localhost:3000
- Authorization callback URL: http://localhost:3000/auth/github/callback
4. 取得 Client ID 和 Client Secret
*/

const express = require('express');
const axios = require('axios');
const app = express();

const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/github/callback';

// ========== GitHub 登入 ==========
app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';

const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email', // 請求的權限
state: Math.random().toString(36) // CSRF 防護
});

res.redirect(`${authUrl}?${params}`);
});

// ========== 處理回呼 ==========
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;

try {
// 1. 取得 Access Token
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI
},
{
headers: { Accept: 'application/json' }
}
);

const { access_token } = tokenResponse.data;

// 2. 取得使用者資訊
const userResponse = await axios.get('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`
}
});

const user = userResponse.data;
/*
{
login: "username",
id: 12345,
avatar_url: "https://avatars.githubusercontent.com/...",
name: "王小明",
email: "user@example.com",
bio: "我是開發者",
public_repos: 50
}
*/

// 3. 取得電子郵件 (另外請求)
const emailResponse = await axios.get('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${access_token}`
}
});

const primaryEmail = emailResponse.data.find(email => email.primary);

res.json({
user,
email: primaryEmail.email
});
} catch (error) {
console.error('GitHub OAuth 錯誤:', error.message);
res.status(500).send('認證失敗');
}
});

app.listen(3000);

在 React 中使用 OAuth

// ========== React 用戶端 ==========

import React, { useEffect, useState } from 'react';
import axios from 'axios';

function App() {
const [user, setUser] = useState(null);

// 頁面載入時檢查使用者資訊
useEffect(() => {
checkAuth();
}, []);

async function checkAuth() {
try {
const response = await axios.get('/api/me', {
withCredentials: true // 包含 Cookie
});
setUser(response.data);
} catch (error) {
console.log('未登入');
}
}

function handleGoogleLogin() {
// 移動到伺服器的 OAuth 端點
window.location.href = 'http://localhost:3000/auth/google';
}

function handleGitHubLogin() {
window.location.href = 'http://localhost:3000/auth/github';
}

function handleLogout() {
window.location.href = 'http://localhost:3000/logout';
}

if (user) {
return (
<div>
<h1>你好, {user.name}!</h1>
<img src={user.picture} alt="個人資料" />
<p>{user.email}</p>
<button onClick={handleLogout}>登出</button>
</div>
);
}

return (
<div>
<h1>登入</h1>
<button onClick={handleGoogleLogin}>
🔵 使用 Google 登入
</button>
<button onClick={handleGitHubLogin}>
⚫ 使用 GitHub 登入
</button>
</div>
);
}

export default App;

使用 PKCE 增強安全性 (SPA/行動)

// ========== PKCE (Proof Key for Code Exchange) ==========
// 用於行動應用程式或 SPA

const crypto = require('crypto');

// ========== 1. 產生 Code Verifier ==========
function generateCodeVerifier() {
return base64URLEncode(crypto.randomBytes(32));
}

// ========== 2. 產生 Code Challenge ==========
function generateCodeChallenge(verifier) {
const hash = crypto.createHash('sha256')
.update(verifier)
.digest();
return base64URLEncode(hash);
}

function base64URLEncode(buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

// ========== 3. 開始登入 (用戶端) ==========
async function startPKCELogin() {
// 產生並儲存 Verifier
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);

// 產生 Challenge
const codeChallenge = generateCodeChallenge(codeVerifier);

// Google 認證 URL
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
code_challenge: codeChallenge, // PKCE
code_challenge_method: 'S256' // SHA-256
});

window.location.href = `${authUrl}?${params}`;
}

// ========== 4. 處理回呼 ==========
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');

// 取得儲存的 Verifier
const codeVerifier = sessionStorage.getItem('code_verifier');

// Token 交換
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // PKCE Verifier
})
});

const { access_token } = await response.json();

// 刪除 Verifier
sessionStorage.removeItem('code_verifier');

return access_token;
}

🤔 常見問題

Q1. OAuth vs JWT vs Session?

A:

// ========== 1. Session (工作階段) ==========
// 傳統方式

// 登入
app.post('/login', (req, res) => {
const { username, password } = req.body;

// 確認認證
if (isValidUser(username, password)) {
// 儲存到工作階段
req.session.userId = user.id;
res.json({ message: '登入成功' });
}
});

// 確認認證
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: '需要登入' });
}

const user = getUserById(req.session.userId);
res.json(user);
});

優點: 簡單,伺服器完全控制
缺點: 需要伺服器記憶體/儲存,擴展性低

// ========== 2. JWT (JSON Web Token) ==========
// 無狀態認證

// 登入
app.post('/login', (req, res) => {
const { username, password } = req.body;

if (isValidUser(username, password)) {
// 產生 JWT
const token = jwt.sign(
{ userId: user.id, username: user.username },
SECRET_KEY,
{ expiresIn: '1h' }
);

res.json({ token });
}
});

// 確認認證
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, SECRET_KEY);
const user = getUserById(decoded.userId);
res.json(user);
} catch (error) {
res.status(401).json({ error: '無效的 Token' });
}
});

優點: 無狀態,擴展性好
缺點: 難以撤銷,體積大

// ========== 3. OAuth 2.0 ==========
// 第三方認證和授權

// 使用者在 Google 登入
// → Google 發行 Access Token
// → 我們的應用程式用 Token 呼叫 Google API

優點:
- 不共享密碼
- 精細控制權限
- 標準協定
- 可以社交登入

缺點:
- 複雜
- 依賴外部服務

// ========== 比較 ==========
特性 | Session | JWT | OAuth
-----------|----------|----------|-------
儲存位置 | 伺服器 | 用戶端 | 伺服器
擴展性 |||
安全性 |||
複雜度 |||
第三方認證 | 不可 | 不可 | 可以

// ========== 組合使用 ==========
// 用 OAuth 登入後發行 JWT

app.get('/auth/google/callback', async (req, res) => {
// 1. 用 Google OAuth 認證使用者
const googleUser = await getGoogleUser(req.query.code);

// 2. 儲存使用者到我們的資料庫
const user = await saveOrUpdateUser(googleUser);

// 3. 產生並回傳 JWT
const token = jwt.sign(
{ userId: user.id, email: user.email },
SECRET_KEY,
{ expiresIn: '7d' }
);

res.json({ token });
});

// 之後的請求用 JWT 認證
app.get('/api/profile', authenticateJWT, (req, res) => {
res.json(req.user);
});

Q2. Access Token 和 Refresh Token 的差異?

A:

// ========== Access Token ==========
// 短期效期 (15分鐘~1小時)
// API 請求時使用

// ========== Refresh Token ==========
// 長期效期 (7天~30天)
// 更新 Access Token 時使用

// ========== 實作範例 ==========

// 登入時同時發行兩者
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);

// Access Token (15分鐘)
const accessToken = jwt.sign(
{ userId: user.id, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);

// Refresh Token (7天)
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);

// Refresh Token 儲存到資料庫 (為了撤銷)
await saveRefreshToken(user.id, refreshToken);

res.json({ accessToken, refreshToken });
});

// ========== API 請求 (Access Token) ==========
app.get('/api/data', authenticateAccess, (req, res) => {
res.json({ data: '重要資料' });
});

function authenticateAccess(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, ACCESS_SECRET);

if (decoded.type !== 'access') {
throw new Error('錯誤的 Token 類型');
}

req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'access_token_expired',
message: '請用 Refresh Token 更新'
});
}

res.status(401).json({ error: '無效的 Token' });
}
}

// ========== 更新 Access Token ==========
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;

if (!refreshToken) {
return res.status(401).json({ error: '需要 Refresh Token' });
}

try {
// 1. 驗證 Refresh Token
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

if (decoded.type !== 'refresh') {
throw new Error('錯誤的 Token 類型');
}

// 2. 從資料庫確認 (是否撤銷)
const isValid = await isRefreshTokenValid(decoded.userId, refreshToken);
if (!isValid) {
throw new Error('已撤銷的 Refresh Token');
}

// 3. 發行新的 Access Token
const newAccessToken = jwt.sign(
{ userId: decoded.userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: '無效的 Refresh Token' });
}
});

// ========== 前端自動更新 ==========
import axios from 'axios';

let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');

// Axios 攔截器
axios.interceptors.request.use(
config => {
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
}
);

axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

// Access Token 過期
if (error.response?.data?.error === 'access_token_expired' &&
!originalRequest._retry) {
originalRequest._retry = true;

try {
// 用 Refresh Token 更新
const response = await axios.post('/auth/refresh', {
refreshToken
});

accessToken = response.data.accessToken;
localStorage.setItem('accessToken', accessToken);

// 重試原始請求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh Token 也過期 → 登出
localStorage.clear();
window.location.href = '/login';
}
}

return Promise.reject(error);
}
);

// ========== 安全提示 ==========

// ✅ 良好範例
1. Access Token: 短期效期 (15分鐘)
2. Refresh Token: 儲存到資料庫以便撤銷
3. Refresh Token 儲存在 HttpOnly Cookie
4. 必須使用 HTTPS
5. Refresh Token Rotation (更新時發行新 Token)

// ❌ 不良範例
1. Access Token 效期太長
2. Refresh Token 儲存在 localStorage
3. Refresh Token 無法撤銷
4. 使用 HTTP

Q3. Scope(權限範圍)是什麼?

A:

// ========== Scope 概念 ==========
// 應用程式請求的權限範圍

// ========== Google OAuth Scopes ==========
const scopes = [
'openid', // 基本資訊
'email', // 電子郵件
'profile', // 個人資料 (姓名,照片)
'https://www.googleapis.com/auth/drive.readonly', // Drive 讀取
'https://www.googleapis.com/auth/gmail.send' // Gmail 傳送
];

// 登入時請求權限
app.get('/auth/google', (req, res) => {
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scopes.join(' ') // 用空格分隔
});

res.redirect(`${authUrl}?${params}`);
});

// 向使用者顯示的畫面:
/*
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
My App 請求以下權限:

✓ 檢視電子郵件地址
✓ 檢視基本個人資料
✓ 檢視 Google Drive 檔案
✓ 傳送 Gmail 電子郵件

[允許] [拒絕]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
*/

// ========== GitHub OAuth Scopes ==========
const githubScopes = [
'user', // 使用者資訊
'user:email', // 電子郵件 (包含私人)
'repo', // 完整存取儲存庫
'public_repo', // 僅公開儲存庫
'read:org', // 讀取組織資訊
'write:org' // 寫入組織資訊
];

app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email public_repo' // 僅需要的權限
});

res.redirect(`${authUrl}?${params}`);
});

// ========== 最小權限原則 ==========

// ❌ 不良範例: 請求過多權限
scope: 'user repo admin:org' // 太多權限!

// ✅ 良好範例: 僅請求必要權限
scope: 'user:email' // 只需要電子郵件

// ========== 動態 Scope 請求 ==========

// 初始登入: 僅基本資訊
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile' // 僅基本資訊
});

res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// 稍後需要額外權限時
app.get('/auth/google/drive', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.readonly',
prompt: 'consent' // 請求重新同意
});

res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// ========== 漸進式授權 ==========
// 需要時逐步新增權限

// 第1階段: 登入 (基本資訊)
scope: 'email profile'

// 第2階段: Google Drive 整合 (使用者想要時)
scope: 'https://www.googleapis.com/auth/drive'

// 第3階段: Gmail 整合 (使用者想要時)
scope: 'https://www.googleapis.com/auth/gmail.send'

// ========== Scope 驗證 ==========

// 檢查 Access Token 中包含的 scope
async function checkScope(accessToken, requiredScope) {
const response = await axios.get(
'https://www.googleapis.com/oauth2/v1/tokeninfo',
{ params: { access_token: accessToken } }
);

const grantedScopes = response.data.scope.split(' ');

if (!grantedScopes.includes(requiredScope)) {
throw new Error('權限不足');
}
}

// 使用範例
app.get('/api/drive/files', async (req, res) => {
const accessToken = req.headers.authorization?.split(' ')[1];

try {
// 檢查 Drive 權限
await checkScope(accessToken, 'https://www.googleapis.com/auth/drive.readonly');

// 呼叫 Drive API
const files = await listDriveFiles(accessToken);
res.json(files);
} catch (error) {
res.status(403).json({
error: '權限不足',
message: '需要 Google Drive 存取權限'
});
}
});

Q4. OAuth 安全性最佳實務?

A:

// ========== 1. HTTPS 必須 ==========
// ✅ 總是使用 HTTPS
const REDIRECT_URI = 'https://myapp.com/callback';

// ❌ 絕對禁止 HTTP
const REDIRECT_URI = 'http://myapp.com/callback'; // Token 竊取風險!

// ========== 2. State 參數 (防止 CSRF) ==========

// 開始登入
app.get('/auth/google', (req, res) => {
// 產生並儲存隨機 state
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;

const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile',
state // 防止 CSRF
});

res.redirect(`${authUrl}?${params}`);
});

// 在回呼中驗證
app.get('/auth/google/callback', (req, res) => {
const { state, code } = req.query;

// 檢查 state
if (state !== req.session.oauthState) {
return res.status(403).send('偵測到 CSRF 攻擊');
}

// 刪除 state
delete req.session.oauthState;

// ... 繼續處理
});

// ========== 3. Client Secret 保護 ==========

// ✅ 使用環境變數
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;

// ✅ 僅在伺服器使用
// 絕對不要暴露給前端!

// ❌ 禁止寫在程式碼中
const CLIENT_SECRET = 'abc123xyz'; // 絕對禁止!

// ========== 4. Redirect URI 驗證 ==========

// 在 Google Cloud Console 註冊的 URI 僅允許
// ✅ 完全符合的 URI
https://myapp.com/auth/callback

// ❌ 禁止使用萬用字元
https://myapp.com/*

// ❌ 開放重新導向弱點
https://myapp.com/callback?redirect=evil.com

// ========== 5. Token 儲存 ==========

// ✅ 伺服器: 環境變數或加密的資料庫
await saveToken(userId, encryptToken(accessToken));

// ✅ 用戶端: HttpOnly Cookie (防止 XSS)
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true, // 僅 HTTPS
sameSite: 'strict',
maxAge: 3600000 // 1小時
});

// ❌ localStorage (XSS 弱點)
localStorage.setItem('token', accessToken); // 危險!

// ========== 6. Token 過期時間 ==========

// ✅ 短期效期
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m' // 15分鐘
});

// ✅ 使用 Refresh Token
const refreshToken = jwt.sign(payload, secret, {
expiresIn: '7d' // 7天
});

// ❌ 無過期時間
const token = jwt.sign(payload, secret); // 危險!

// ========== 7. Scope 最小化 ==========

// ✅ 僅請求必要權限
scope: 'email profile'

// ❌ 請求所有權限
scope: 'https://www.googleapis.com/auth/drive' // 不必要

// ========== 8. 實作 Token 撤銷 ==========

// 登出時撤銷 Token
app.get('/logout', async (req, res) => {
const accessToken = req.cookies.access_token;

// 撤銷 Google Token
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);

// 從資料庫刪除 Refresh Token
await deleteRefreshToken(req.user.id);

// 刪除 Cookie
res.clearCookie('access_token');

res.redirect('/');
});

// ========== 9. 使用 PKCE (行動/SPA) ==========

// 無 Client Secret 增強安全性
const codeVerifier = generateRandomString(128);
const codeChallenge = base64URLEncode(sha256(codeVerifier));

// 認證請求時傳送 challenge
const params = {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};

// Token 交換時傳送 verifier
const tokenParams = {
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // 取代 Secret
};

// ========== 10. 定期 Token 更新 ==========

// 在背景自動更新
setInterval(async () => {
try {
const newAccessToken = await refreshAccessToken(refreshToken);
updateToken(newAccessToken);
} catch (error) {
console.error('Token 更新失敗:', error);
redirectToLogin();
}
}, 14 * 60 * 1000); // 每14分鐘 (15分鐘過期前)

Q5. 認證(Authentication) vs 授權(Authorization)?

A:

// ========== 認證 (Authentication) ==========
// "你是誰?" - 確認身分

// 範例: 登入
const user = await authenticateUser(username, password);
if (user) {
console.log('你是王小明 ✓');
}

// ========== 授權 (Authorization) ==========
// "你可以做什麼?" - 確認權限

// 範例: 僅管理員存取
if (user.role === 'admin') {
console.log('可以使用管理員功能 ✓');
} else {
console.log('沒有權限 ✗');
}

// ========== OAuth 2.0 的角色 ==========

// OAuth 主要是 Authorization (授權)
// - 使用者授權應用程式
// - "允許這個應用程式檢視我的 Google Drive"

// 但也用於認證(Authentication)
// - OpenID Connect (OAuth 2.0 + 認證)
// - "使用 Google 登入"

// ========== 實際範例 ==========

// 第1階段: 認證 (Authentication)
app.post('/login', async (req, res) => {
const { username, password } = req.body;

// 確認使用者身分
const user = await User.findOne({ username });

if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: '認證失敗' });
}

// 認證成功 → 發行 Token
const token = jwt.sign(
{
userId: user.id,
role: user.role // 包含權限資訊
},
SECRET_KEY
);

res.json({ token });
});

// 第2階段: 授權 (Authorization)
app.get('/admin/users', authenticateToken, authorizeAdmin, (req, res) => {
// 僅管理員可存取
const users = await User.findAll();
res.json(users);
});

function authenticateToken(req, res, next) {
// 認證: Token 驗證
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: '需要認證' });
}
}

function authorizeAdmin(req, res, next) {
// 授權: 確認管理員
if (req.user.role !== 'admin') {
return res.status(403).json({ error: '沒有權限' });
}
next();
}

// ========== 用 OAuth 認證 + 授權 ==========

// 1. 認證 (OpenID Connect)
app.get('/auth/google/callback', async (req, res) => {
// 用 Google OAuth 確認使用者
const googleUser = await getGoogleUser(req.query.code);

// 認證完成 → 確認使用者身分
console.log('使用者:', googleUser.email);

// 登入我們的系統
const user = await findOrCreateUser(googleUser);
req.session.userId = user.id;
});

// 2. 授權 (Scope)
app.get('/api/drive/files', async (req, res) => {
// 檢查 Google Drive 權限
const hasPermission = await checkGoogleScope(
req.user.accessToken,
'https://www.googleapis.com/auth/drive.readonly'
);

if (!hasPermission) {
return res.status(403).json({
error: '需要權限',
message: '請授予 Google Drive 存取權限'
});
}

// 有權限 → 呼叫 API
const files = await listDriveFiles(req.user.accessToken);
res.json(files);
});

// ========== 比較整理 ==========

認證 (Authentication)
- 問題: "你是誰?"
- 目的: 確認身分
- 範例:
* 輸入密碼
* 指紋辨識
* 使用 Google 登入
- HTTP 狀態碼: 401 Unauthorized

授權 (Authorization)
- 問題: "你可以做什麼?"
- 目的: 確認存取權限
- 範例:
* 管理員權限
* Google Drive 讀取權限
* OAuth Scope
- HTTP 狀態碼: 403 Forbidden

// 兩者都需要!
// 1. 先認證 (登入)
// 2. 然後確認權限 (允許存取)

🎓 下一步

理解 OAuth 2.0 後,請學習:

  1. JWT Token (文件準備中) - 基於 Token 的認證
  2. 什麼是 HTTPS? (文件準備中) - 安全通訊
  3. 什麼是 CORS? (文件準備中) - API 安全性

實際演練

# ========== 1. Google OAuth 實習 ==========

# 建立專案
mkdir oauth-demo
cd oauth-demo
npm init -y

# 安裝套件
npm install express axios express-session dotenv

# 建立 .env 檔案
cat > .env << EOF
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
SESSION_SECRET=your_session_secret
EOF

# 撰寫 server.js 後執行
node server.js

# 造訪 http://localhost:3000

# ========== 2. Passport.js 實習 ==========

npm install passport passport-google-oauth20

# ========== 3. 實作多個社交登入 ==========

npm install passport-github2 passport-facebook

# 同時支援 Google、GitHub、Facebook

🎬 總結

OAuth 2.0 是現代 Web 的標準認證協定:

  • 安全性: 無需共享密碼的安全認證
  • 便利性: 社交登入實現快速註冊
  • 權限控制: 只授予必要的權限
  • 標準: 所有主要服務都支援

構建安全便捷的認證系統! 🔐