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

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

现代SPA认证架构

引言:没有银弹,但有最佳组合

前面我们分别讲了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
// login.js
async function login() {
// 调用BFF的登录端点
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include' // 包含Cookie
})

if (response.ok) {
// 登录成功,BFF会设置HttpOnly Cookie
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
// auth.js
const express = require('express')
const crypto = require('crypto')
const router = express.Router()

// 生成PKCE参数
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) => {
// 生成PKCE参数
const { codeVerifier, codeChallenge } = generatePKCE()

// 存储codeVerifier(关联会话)
req.session.codeVerifier = codeVerifier

// 构建授权URL
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')

// 返回授权URL给前端
res.json({ authUrl: authUrl.toString() })
})

// 回调端点
router.get('/callback', async (req, res) => {
const { code } = req.query
const { codeVerifier } = req.session

// 用授权码 + 验证器换Token
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()

// 存储Token(服务端)
req.session.accessToken = access_token
req.session.refreshToken = refresh_token

// 设置HttpOnly Cookie(可选,用于标识会话)
res.cookie('session_id', req.sessionID, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24小时
})

// 重定向到前端
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
// api.js
const express = require('express')
const axios = require('axios')
const router = express.Router()

// 认证中间件
const authMiddleware = (req, res, next) => {
// 从Session中获取Token
const accessToken = req.session.accessToken

if (!accessToken) {
return res.status(401).json({ error: 'Not authenticated' })
}

// 验证Token(可选)
if (isTokenExpired(accessToken)) {
// 尝试刷新Token
return refreshToken(req, res, next)
}

req.accessToken = accessToken
next()
}

// 代理API调用
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' })
}
})

// 聚合多个API调用
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
// api.js
class ApiClient {
async request(url, options = {}) {
const response = await fetch(url, {
...options,
credentials: 'include' // 包含Cookie
})

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, // JavaScript无法访问
secure: true, // 只在HTTPS下发送
sameSite: 'strict', // 防止CSRF
maxAge: 24 * 60 * 60 * 1000 // 24小时
})

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也过期,重新登录

总结

核心要点

  1. BFF + PKCE + Cookie是现代SPA认证的最佳组合
  2. BFF处理认证和API聚合
  3. PKCE防止授权码拦截
  4. HttpOnly Cookie防止XSS

架构优势

  • 安全性高:多层防护
  • 前端简单:不需要处理Token
  • 灵活扩展:支持多端
  • 标准兼容:遵循OAuth2标准

给开发者的建议

  • 评估架构需求:不是所有项目都需要这个架构
  • 使用成熟框架:不要自己实现认证逻辑
  • 安全第一:在功能和安全之间,选择安全
  • 持续学习:认证技术在不断演进

现代SPA认证没有银弹,但BFF + PKCE + Cookie是目前最安全、最灵活的组合。