JWT在前后端分离项目中的应用
1. 前后端分离项目中的安全认证问题
在传统的 Web 应用开发中,服务器端负责渲染完整的 HTML 页面并返回给客户端。这种模式下,用户认证通常依赖于服务器端生成并管理的会话(Session)。服务器通过在客户端浏览器中设置 Cookie 来标识用户会话,每次客户端发起请求时,Cookie 会被包含在请求头中发送到服务器,服务器根据 Cookie 中的会话标识来验证用户身份。
然而,随着前后端分离架构的流行,前端应用作为独立的单页应用(SPA)运行在浏览器中,与后端服务器通过 API 进行通信。这种架构下,传统的基于 Session - Cookie 的认证方式面临诸多挑战:
- 跨域问题:由于前后端可能部署在不同的域名下,Cookie 在跨域请求时存在限制,需要额外的配置来处理跨域资源共享(CORS),增加了开发的复杂性。
- 服务器负载:每个用户的会话信息都存储在服务器端,随着用户数量的增加,服务器需要管理大量的会话数据,这对服务器的内存和性能造成较大压力。
- 移动端支持:在移动应用开发中,使用 Cookie 进行认证不太方便,移动应用更倾向于使用轻量级的认证机制。
因此,我们需要一种适用于前后端分离架构的安全认证方案,JSON Web Token(JWT)应运而生。
2. JWT 简介
JWT 是一种基于 JSON 的开放标准(RFC 7519),用于在网络应用环境间传递声明。这些声明以 JSON 对象的形式被编码在 JWT 中,可以被数字签名或加密。JWT 通常由三部分组成:头部(Header)、载荷(Payload)和签名(Signature),它们之间通过点(.)分隔,形成类似于 xxxxx.yyyyy.zzzzz
的字符串。
2.1 JWT 的结构
- 头部(Header):头部通常由两部分组成:令牌的类型(即 JWT)和所使用的签名算法,如 HMAC SHA256 或 RSA。例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后将这个 JSON 对象进行 Base64Url 编码,形成 JWT 的第一部分。
- 载荷(Payload):载荷是 JWT 的第二部分,其中包含声明(claims)。声明是关于实体(通常指用户)和其他数据的陈述。JWT 有三种类型的声明:注册声明(如 iss、exp、sub 等,这些是建议但不强制使用的)、公共声明(可以由使用 JWT 的各方定义)和私有声明(用于在同意使用它们的各方之间共享信息,并且不是注册声明或公共声明)。例如:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
同样,将这个 JSON 对象进行 Base64Url 编码,形成 JWT 的第二部分。
- 签名(Signature):要创建签名部分,需要使用编码后的头部、编码后的载荷、一个密钥(secret)和头部中指定的签名算法。例如,如果使用 HMAC SHA256 算法,签名将按如下方式创建:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
签名用于验证消息在传输过程中没有被更改,并且,当使用私钥签名时,还可以验证 JWT 的发送者的身份。
2.2 JWT 的工作原理
在前后端分离的应用中,当用户登录成功后,后端服务器会生成一个 JWT,其中包含用户的相关信息(如用户 ID、用户名等)和一些元数据(如过期时间等)。服务器将这个 JWT 返回给前端应用。前端应用在后续的请求中,会将 JWT 包含在请求头(通常是 Authorization
头,格式为 Bearer <token>
)中发送给后端服务器。后端服务器接收到请求后,从请求头中提取 JWT,并使用相同的密钥和签名算法对 JWT 进行验证。如果验证通过,服务器就可以从 JWT 中获取用户信息,并根据这些信息进行授权和其他操作。
3. 在后端开发中使用 JWT
在后端开发中,使用 JWT 主要涉及生成 JWT、验证 JWT 和从 JWT 中提取用户信息等操作。下面以常见的后端开发语言为例,介绍如何实现这些功能。
3.1 Node.js(使用 Express 框架)
首先,安装 jsonwebtoken
库,这是 Node.js 中常用的 JWT 处理库:
npm install jsonwebtoken
生成 JWT:
const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();
const secretKey = 'your-secret-key';
app.post('/login', (req, res) => {
// 假设这里进行了用户登录验证,验证成功后
const user = { id: 1, username: 'testUser' };
const token = jwt.sign(user, secretKey, { expiresIn: '1h' });
res.json({ token });
});
验证 JWT:
app.get('/protected', (req, res) => {
const token = req.headers['authorization'];
if (!token) return res.status(401).send('Access denied. No token provided.');
const bearerToken = token.split(' ')[1];
jwt.verify(bearerToken, secretKey, (err, decoded) => {
if (err) return res.status(400).send('Invalid token');
res.json(decoded);
});
});
3.2 Python(使用 Flask 框架)
安装 PyJWT
库:
pip install PyJWT
生成 JWT:
from flask import Flask, jsonify, request
import jwt
from datetime import datetime, timedelta
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'
@app.route('/login', methods=['POST'])
def login():
# 假设这里进行了用户登录验证,验证成功后
user = {'id': 1, 'username': 'testUser'}
expiration = datetime.utcnow() + timedelta(hours = 1)
token = jwt.encode({'user': user, 'exp': expiration}, app.config['SECRET_KEY'], algorithm='HS256')
return jsonify({'token': token})
验证 JWT:
@app.route('/protected', methods=['GET'])
def protected():
token = None
if 'Authorization' in request.headers:
token = request.headers['Authorization'].split(' ')[1]
if not token:
return jsonify({'message': 'Access denied. No token provided.'}), 401
try:
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
return jsonify(data)
except jwt.ExpiredSignatureError:
return jsonify({'message': 'Token has expired'}), 400
except jwt.InvalidTokenError:
return jsonify({'message': 'Invalid token'}), 400
3.3 Java(使用 Spring Boot 框架)
添加 jjwt
依赖到 pom.xml
:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
生成 JWT:
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@RestController
public class AuthController {
private static final String SECRET_KEY = "your-secret-key";
@PostMapping("/login")
public String login() {
// 假设这里进行了用户登录验证,验证成功后
Map<String, Object> claims = new HashMap<>();
claims.put("sub", 1);
claims.put("name", "testUser");
claims.put("iat", new Date());
claims.put("exp", new Date(System.currentTimeMillis() + 60 * 60 * 1000));
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS256, SECRET_KEY)
.compact();
return token;
}
}
验证 JWT:
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProtectedController {
private static final String SECRET_KEY = "your-secret-key";
@GetMapping("/protected")
public Claims protectedResource(@RequestHeader("Authorization") String authorizationHeader) {
String token = authorizationHeader.substring(7);
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
return claims;
} catch (SignatureException e) {
throw new RuntimeException("Invalid JWT signature");
}
}
}
4. 在前端开发中使用 JWT
在前端应用中,当用户登录成功获取到 JWT 后,需要妥善保存并在后续请求中发送给后端服务器。常见的做法是将 JWT 存储在浏览器的本地存储(Local Storage)或会话存储(Session Storage)中,或者使用 Cookie 存储。然而,由于安全原因,使用本地存储或会话存储时,需要注意防止 XSS 攻击,因为恶意脚本可能会获取到存储在其中的 JWT。使用 Cookie 时,需要设置 HttpOnly
属性来防止 XSS 攻击获取 Cookie 中的 JWT。
以下以 Vue.js 为例,介绍前端如何使用 JWT。
4.1 保存 JWT
假设使用 vuex
来管理应用状态,在用户登录成功后,将 JWT 保存到 vuex
中:
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
token: null
},
mutations: {
setToken(state, token) {
state.token = token;
localStorage.setItem('token', token);
}
},
actions: {
login({ commit }, userData) {
// 这里假设通过 API 进行登录验证,登录成功后
const token = 'your-jwt-token';
commit('setToken', token);
}
}
});
export default store;
4.2 在请求中发送 JWT
使用 axios
作为 HTTP 客户端,在每次请求时将 JWT 添加到请求头中:
import axios from 'axios';
import store from './store';
const instance = axios.create();
instance.interceptors.request.use(config => {
const token = store.state.token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default instance;
5. JWT 的安全性考量
虽然 JWT 为前后端分离项目提供了一种便捷的安全认证方案,但在使用过程中也需要注意一些安全性问题。
5.1 密钥管理
JWT 的签名密钥非常关键,如果密钥泄露,攻击者就可以伪造 JWT,从而绕过认证和授权机制。因此,密钥应该妥善保管,不要在代码中明文存储,最好使用环境变量来配置密钥。在生产环境中,密钥应该足够复杂且保密。
5.2 防止重放攻击
由于 JWT 是自包含的且在有效期内始终有效,攻击者可能会捕获并重用有效的 JWT 进行重放攻击。为了防止重放攻击,可以在 JWT 中添加一个唯一的标识符(如 jti
声明),并在服务器端维护一个已使用的 JWT 列表。每次验证 JWT 时,检查其标识符是否已在列表中使用过。另外,设置较短的 JWT 有效期也可以降低重放攻击的风险。
5.3 防止 XSS 攻击
如前文所述,在前端存储 JWT 时,如果使用本地存储或会话存储,需要防止 XSS 攻击。尽量避免在页面中直接使用 JavaScript 操作 JWT,并且对用户输入进行严格的过滤和验证,防止恶意脚本注入。
5.4 防止 CSRF 攻击
虽然 JWT 本身不依赖于 Cookie,在一定程度上减少了 CSRF 攻击的风险,但如果前端应用同时使用 Cookie(例如用于其他目的),仍然需要采取措施防止 CSRF 攻击。可以通过在前端页面生成并包含 CSRF 令牌,在每次请求时将令牌发送到后端进行验证。
6. JWT 的应用场景
JWT 在前后端分离项目中有广泛的应用场景:
- 用户认证:作为用户登录后的身份验证凭证,后端根据 JWT 验证用户身份并进行授权。
- 单点登录(SSO):在多个相关的应用系统之间实现单点登录,用户只需在一个系统中登录,获取的 JWT 可以在其他系统中进行身份验证。
- 微服务架构:在微服务之间传递用户身份和权限信息,每个微服务可以独立验证 JWT,实现分布式系统中的统一认证和授权。
通过合理使用 JWT,并结合相关的安全措施,能够有效地解决前后端分离项目中的安全认证问题,提高应用的安全性和可扩展性。在实际项目中,需要根据具体的业务需求和安全要求,灵活选择和配置 JWT 的使用方式。