现代SPA认证架构:BFF + PKCE + Cookie的最佳组合

引言:没有银弹,但有最佳组合
前面我们分别讲了SPA认证的困境、BFF模式和PKCE。
今天,我们把它们组合起来,看看现代SPA应用的最佳认证架构是什么样的。
第一章:为什么需要组合?
单一方案的局限
只用Cookie:
- ✅ 安全(HttpOnly)
- ❌ CSRF风险
- ❌ 跨域复杂
只用PKCE:
- ✅ 防止授权码拦截
- ❌ Token仍然在前端
- ❌ XSS风险
只用BFF:
- ✅ 前端不处理Token
- ❌ 需要额外服务
- ❌ 架构复杂
组合的优势
BFF + PKCE + Cookie:
- ✅ 安全性高
- ✅ 前端不处理Token
- ✅ 防止授权码拦截
- ✅ 防止XSS和CSRF
第二章:架构总览

整体架构
1 2 3 4 5 6 7 8 9
| ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ SPA │ │ BFF │ │ Auth │ │ (前端) │────▶│ (后端) │────▶│ Server │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ Cookie │ Token │ │ (HttpOnly) │ (OAuth2) │ │ │ │ └────────────────────┴────────────────────┘
|
组件职责
| 组件 |
职责 |
| SPA |
展示界面,调用BFF API |
| BFF |
处理认证,聚合API,管理Token |
| Auth Server |
验证用户身份,签发Token |
数据流
登录流程:
1 2 3 4 5 6 7 8
| 1. SPA → BFF: 请求登录 2. BFF → Auth Server: 发送授权请求(PKCE) 3. Auth Server → 用户: 显示登录页面 4. 用户 → Auth Server: 输入用户名密码 5. Auth Server → BFF: 返回授权码 6. BFF → Auth Server: 用授权码 + 验证器换Token 7. BFF → SPA: 设置HttpOnly Cookie 8. SPA → 用户: 登录成功
|
API调用流程:
1 2 3 4 5
| 1. SPA → BFF: 调用API(自动携带Cookie) 2. BFF: 验证Cookie,获取Token 3. BFF → 后端服务: 调用API(携带Token) 4. 后端服务 → BFF: 返回数据 5. BFF → SPA: 返回数据
|
第三章:详细实现
步骤一:SPA发起登录
前端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| async function login() { const response = await fetch('/api/auth/login', { method: 'POST', credentials: 'include' }) if (response.ok) { window.location.href = '/dashboard' } else { showError('Login failed') } }
|
步骤二:BFF处理认证
BFF代码:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| const express = require('express') const crypto = require('crypto') const router = express.Router()
function generatePKCE() { const codeVerifier = crypto.randomBytes(96).toString('base64url') const codeChallenge = crypto.createHash('sha256') .update(codeVerifier) .digest('base64url') return { codeVerifier, codeChallenge } }
router.post('/login', async (req, res) => { const { codeVerifier, codeChallenge } = generatePKCE() req.session.codeVerifier = codeVerifier const authUrl = new URL('https://auth-server.com/authorize') authUrl.searchParams.set('client_id', process.env.CLIENT_ID) authUrl.searchParams.set('redirect_uri', process.env.REDIRECT_URI) authUrl.searchParams.set('response_type', 'code') authUrl.searchParams.set('scope', 'openid profile email') authUrl.searchParams.set('code_challenge', codeChallenge) authUrl.searchParams.set('code_challenge_method', 'S256') res.json({ authUrl: authUrl.toString() }) })
router.get('/callback', async (req, res) => { const { code } = req.query const { codeVerifier } = req.session const tokenResponse = await fetch('https://auth-server.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: process.env.CLIENT_ID, redirect_uri: process.env.REDIRECT_URI, code_verifier: codeVerifier }) }) const { access_token, refresh_token } = await tokenResponse.json() req.session.accessToken = access_token req.session.refreshToken = refresh_token res.cookie('session_id', req.sessionID, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000 }) res.redirect('/dashboard') })
module.exports = router
|
步骤三:BFF代理API调用
BFF代码:
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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| const express = require('express') const axios = require('axios') const router = express.Router()
const authMiddleware = (req, res, next) => { const accessToken = req.session.accessToken if (!accessToken) { return res.status(401).json({ error: 'Not authenticated' }) } if (isTokenExpired(accessToken)) { return refreshToken(req, res, next) } req.accessToken = accessToken next() }
router.get('/user/profile', authMiddleware, async (req, res) => { try { const response = await axios.get('http://user-service/users/me', { headers: { 'Authorization': `Bearer ${req.accessToken}` } }) res.json(response.data) } catch (error) { res.status(500).json({ error: 'Failed to fetch profile' }) } })
router.get('/dashboard', authMiddleware, async (req, res) => { try { const [user, orders, notifications] = await Promise.all([ axios.get('http://user-service/users/me', { headers: { 'Authorization': `Bearer ${req.accessToken}` } }), axios.get('http://order-service/orders?limit=5', { headers: { 'Authorization': `Bearer ${req.accessToken}` } }), axios.get('http://notification-service/notifications?unread=true', { headers: { 'Authorization': `Bearer ${req.accessToken}` } }) ]) res.json({ user: user.data, recentOrders: orders.data, unreadNotifications: notifications.data }) } catch (error) { res.status(500).json({ error: 'Failed to fetch dashboard data' }) } })
module.exports = router
|
步骤四:前端调用API
前端代码:
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
| class ApiClient { async request(url, options = {}) { const response = await fetch(url, { ...options, credentials: 'include' }) if (response.status === 401) { window.location.href = '/login' return } return response.json() } async getProfile() { return this.request('/api/user/profile') } async getDashboard() { return this.request('/api/dashboard') } }
const api = new ApiClient()
const profile = await api.getProfile() console.log(profile)
const dashboard = await api.getDashboard() console.log(dashboard)
|
第四章:安全考量

安全特性
| 特性 |
实现方式 |
| 防止XSS |
Token存储在服务端,HttpOnly Cookie |
| 防止CSRF |
SameSite Cookie,验证Referer |
| 防止授权码拦截 |
PKCE |
| 防止中间人攻击 |
HTTPS |
| 防止重放攻击 |
Token过期机制 |
安全配置
Cookie配置:
1 2 3 4 5 6
| res.cookie('session_id', sessionId, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000 })
|
CORS配置:
1 2 3 4 5 6
| app.use(cors({ origin: 'https://your-spa.com', credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type'] }))
|
CSP配置:
1 2 3 4 5
| <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
第五章:部署架构
生产环境架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| ┌─────────────┐ │ CDN │ │ (静态资源) │ └──────┬──────┘ │ ┌──────▼──────┐ │ Nginx │ │ (反向代理) │ └──────┬──────┘ │ ┌────────────┴────────────┐ │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ SPA │ │ BFF │ │ (前端) │ │ (后端) │ └─────────────┘ └──────┬──────┘ │ ┌────────┴────────┐ │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ User Service│ │Order Service│ └─────────────┘ └─────────────┘
|
Docker配置
docker-compose.yml:
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
| version: '3.8'
services: spa: build: ./spa ports: - "3000:80" depends_on: - bff bff: build: ./bff ports: - "3001:3000" environment: - CLIENT_ID=${CLIENT_ID} - CLIENT_SECRET=${CLIENT_SECRET} - SESSION_SECRET=${SESSION_SECRET} depends_on: - user-service - order-service user-service: build: ./user-service ports: - "3002:3000" order-service: build: ./order-service ports: - "3003:3000"
|
第六章:常见问题
Q1:为什么不用JWT直接存储在前端?
答:JWT存储在前端容易被XSS攻击窃取。BFF模式将Token存储在服务端,更安全。
Q2:BFF会不会成为性能瓶颈?
答:会,但可以通过以下方式优化:
Q3:PKCE是必须的吗?
答:对于公开客户端(SPA、移动App),PKCE是推荐的。对于机密客户端(有后端),可以使用客户端密钥。
Q4:如何处理Token过期?
答:
- BFF检测Token过期
- 使用Refresh Token获取新的Access Token
- 如果Refresh Token也过期,重新登录
总结
核心要点
- BFF + PKCE + Cookie是现代SPA认证的最佳组合
- BFF处理认证和API聚合
- PKCE防止授权码拦截
- HttpOnly Cookie防止XSS
架构优势
- 安全性高:多层防护
- 前端简单:不需要处理Token
- 灵活扩展:支持多端
- 标准兼容:遵循OAuth2标准
给开发者的建议
- 评估架构需求:不是所有项目都需要这个架构
- 使用成熟框架:不要自己实现认证逻辑
- 安全第一:在功能和安全之间,选择安全
- 持续学习:认证技术在不断演进
现代SPA认证没有银弹,但BFF + PKCE + Cookie是目前最安全、最灵活的组合。