目录
前言:相比于传统的session或者cookie登录验证,JWT更为安全。JWT 是 JSON Web Token 的缩写,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准((RFC 7519)。定义了一种简洁的,自包含的方法用于通信双方之间以 JSON 对象的形式安全的传递信息。因为数字签名的存在,这些信息是可信的,JWT 可以使用 HMAC 算法或者是 RSA 的公私秘钥对进行签名。
1. JWT简介
1.1 JWT流程
JWT完整流程如下:
主要包含以下几个步骤:
- 用户使用账号和密码发起 POST 请求;
- 服务器使用私钥创建一个 JWT;
- 服务器返回这个 JWT 给浏览器;
- 浏览器将该 JWT 串在请求头中像服务器发送请求;
- 服务器验证该 JWT;
- 返回响应的资源给浏览器。
1.2 数据结构
JWT字符串包含三个部分,依次为:
- 头部:Header
Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。
{
"alg": "HS256",
"typ": "JWT"
}
-
负载:Payload
Payload 部分也是一个 JSON 对象,用来存放实际需要传递的有效信息。有效信息包含三个部分:标准中注册的声明、公共的声明、私有的声明 -
签名:Signature。
Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
Base64URL
前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符 +、 / 和 =,在 URL 里面有特殊含义,所以要被替换掉:= 被省略、+ 替换成 -,/ 替换成 _ 。这就是 Base64URL 算法。
1.3 JWT使用方法
客户端收到服务器返回的 JWT 之后需要在本地做保存。此后,客户端每次与服务器通信,都要带上这个 JWT。一般的的做法是放在 HTTP 请求的头信息 Authorization 字段里面。
Authorization: Bearer <token>
1.4 优势
- 不需要在服务端保存会话信息,特别适用于分布式微服务。
- 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据
- 简洁(Compact):可以通过URL, POST参数或者在HTTP header发送,因为数据量小,传输速度也很快
- 因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
1.5 注意事项
- JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
- JWT 不加密的情况下,不能将秘密数据写入 JWT。
- JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
- JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
- JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
- 为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
2. Springboot整合JWT
2.1 引入依赖
jwt有两种库文件:io.jsonwebtoken:jjwt和com.auth0:java-jwt,前者是轻量级的,后者更全面,这里我们使用com.auth0:java-jwt。
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${java-jwt.version}</version>
<scope>compile</scope>
</dependency>
2.2 修改配置文件
# 生成JWT相关配置
jwt:
private-key-path: auto-gen-1024
expiration-minutes: 1440
algorithm: RSA
存放了密钥,公钥也可以存放在配置文件中,也可以为空。这里我们不设置公钥,仅设置密钥、有效时间、加密算法。
2.3 JWT初始化类
主要需要包含将用户信息序列化为JWT、通过JWT进行字符串解析、验证用户的功能。类中的常量主要包含以下:
private static final String PREFIX = "Bearer ";
private static final String ISSUER = "nwpu.dev";
private static final String SUBJECT = "buildBaseFrame";
private static final String USER_ID_KEY = "id";
private static final String KEY_AUTO_GEN_KEY = "auto-gen";
private static final String KEY_SPILT_SIGN = "-";
- 序列化用户信息
public String createJwt(Long userId) {
Calendar instance = Calendar.getInstance();
Date now = instance.getTime();
instance.add(Calendar.MINUTE, expirationMinutes);
return PREFIX + JWT.create()
.withIssuer(ISSUER)
.withSubject(SUBJECT)
.withIssuedAt(now)
.withExpiresAt(instance.getTime())
.withClaim(USER_ID_KEY, userId.toString())
.sign(createAlgorithm());
}
- 解析JWT字符串
public <T> T parseJwt(String token, Class<T> valueType) throws JsonProcessingException {
if (StringUtils.hasText(token)) {
if (!token.startsWith(PREFIX)) {
throw new JwtException("身份令牌格式错误").setJwtMsg("Authorization要求以\"Bearer \"开头");
}
} else {
throw new NoAuthException();
}
DecodedJWT decodedJwt = JWT.decode(token.substring(PREFIX.length()));
String decoded = new String(Base64.getMimeDecoder().decode(decodedJwt.getPayload()));
return objectMapper.readValue(decoded, valueType);
}
- 验证JWT字符串
public <T> T verifyJwt(String token, Class<T> valueType) throws JsonProcessingException {
if (publicKey == null) {
throw new LogicException("缺少公钥错误: 验证Jwt要求指定传入公钥");
}
if (StringUtils.hasText(token)) {
if (!token.startsWith(PREFIX)) {
throw new JwtException("身份令牌格式错误").setJwtMsg("Authorization要求以\"Bearer \"开头");
}
} else {
throw new NoAuthException();
}
JWTVerifier jwtVerifier = JWT.require(createAlgorithm())
.withIssuer(ISSUER)
.withSubject(SUBJECT)
.withClaimPresence(USER_ID_KEY)
.build();
DecodedJWT jwt;
try {
jwt = jwtVerifier.verify(token.substring(PREFIX.length()));
} catch (JWTVerificationException e) {
log.info("身份令牌无效:{}", e.getMessage());
throw new JwtException("身份令牌无效", e.getCause()).setJwtMsg(e.getMessage());
}
String decoded = new String(Base64.getMimeDecoder().decode(jwt.getPayload()));
return objectMapper.readValue(decoded, valueType);
}
完整代码如下:
@Slf4j
@Component
public class JwtGenerator implements InitializingBean {
private static final String PREFIX = "Bearer ";
private static final String ISSUER = "nwpu.dev";
private static final String SUBJECT = "buildBaseFrame";
private static final String USER_ID_KEY = "id";
private static final String KEY_AUTO_GEN_KEY = "auto-gen";
private static final String KEY_SPILT_SIGN = "-";
@Autowired
private ObjectMapper objectMapper;
/**
* 私钥文件路径,可为:auto_gen_xxx(此模式公钥同步自动生成,不读取公钥路径)、私钥文件路径
*/
@Value("${jwt.private-key-path}")
public String privateKeyPath;
@Value("${jwt.private-key-output-path:}")
public String privateKeyOutputPath;
/**
* 公钥文件路径,可为:空(不加载公钥模式,不可进行验证)、公钥文件路径
*/
@Value("${jwt.public-key-path:}")
public String publicKeyPath;
@Value("${jwt.public-key-output-path:}")
public String publicKeyOutputPath;
/**
* 令牌有效时间(单位:分钟)
*/
@Value("${jwt.expiration-minutes}")
public int expirationMinutes;
@Value("${jwt.algorithm:RSA}")
public String algorithm;
private PrivateKey privateKey;
private PublicKey publicKey;
@Override
public void afterPropertiesSet() throws Exception {
if (privateKeyPath.startsWith(KEY_AUTO_GEN_KEY + KEY_SPILT_SIGN)) {
String[] words = privateKeyPath.split(KEY_SPILT_SIGN);
int keySize = Integer.parseInt(words[words.length - 1]);
KeyPair keyPair = genKeyPair(algorithm, keySize);
privateKey = keyPair.getPrivate();
publicKey = keyPair.getPublic();
if (StringUtils.hasText(privateKeyOutputPath)) {
Files.write(Paths.get(privateKeyOutputPath), privateKey.getEncoded());
}
if (StringUtils.hasText(publicKeyOutputPath)) {
Files.write(Paths.get(publicKeyOutputPath), publicKey.getEncoded());
}
} else {
privateKey = loadPrivateKeyFromFile(algorithm, privateKeyPath);
if (StringUtils.hasText(publicKeyPath)) {
publicKey = loadPublicKeyFromFile(algorithm, publicKeyPath);
}
}
}
/**
* 将 User 基本信息序列化创建 jwt
*/
public String createJwt(Long userId) {
Calendar instance = Calendar.getInstance();
Date now = instance.getTime();
instance.add(Calendar.MINUTE, expirationMinutes);
return PREFIX + JWT.create()
.withIssuer(ISSUER)
.withSubject(SUBJECT)
.withIssuedAt(now)
.withExpiresAt(instance.getTime())
.withClaim(USER_ID_KEY, userId.toString())
.sign(createAlgorithm());
}
/**
* 验证 jwt token,将 payload 反序列化为指定对象并返回,若验证不通过返回 null
*/
public <T> T verifyJwt(String token, Class<T> valueType) throws JsonProcessingException {
if (publicKey == null) {
throw new LogicException("缺少公钥错误: 验证Jwt要求指定传入公钥");
}
if (StringUtils.hasText(token)) {
if (!token.startsWith(PREFIX)) {
throw new JwtException("身份令牌格式错误").setJwtMsg("Authorization要求以\"Bearer \"开头");
}
} else {
throw new NoAuthException();
}
JWTVerifier jwtVerifier = JWT.require(createAlgorithm())
.withIssuer(ISSUER)
.withSubject(SUBJECT)
.withClaimPresence(USER_ID_KEY)
.build();
DecodedJWT jwt;
try {
jwt = jwtVerifier.verify(token.substring(PREFIX.length()));
} catch (JWTVerificationException e) {
log.info("身份令牌无效:{}", e.getMessage());
throw new JwtException("身份令牌无效", e.getCause()).setJwtMsg(e.getMessage());
}
String decoded = new String(Base64.getMimeDecoder().decode(jwt.getPayload()));
return objectMapper.readValue(decoded, valueType);
}
/**
* 解析 JWT
*/
public <T> T parseJwt(String token, Class<T> valueType) throws JsonProcessingException {
if (StringUtils.hasText(token)) {
if (!token.startsWith(PREFIX)) {
throw new JwtException("身份令牌格式错误").setJwtMsg("Authorization要求以\"Bearer \"开头");
}
} else {
throw new NoAuthException();
}
DecodedJWT decodedJwt = JWT.decode(token.substring(PREFIX.length()));
String decoded = new String(Base64.getMimeDecoder().decode(decodedJwt.getPayload()));
return objectMapper.readValue(decoded, valueType);
}
private Algorithm createAlgorithm() {
switch (algorithm) {
case "RSA":
return Algorithm.RSA256((RSAPublicKey) publicKey, (RSAPrivateKey) privateKey);
default:
throw new LogicException("未知的jwt加密算法");
}
}
private KeyPair genKeyPair(String keyAlgorithm, int keySize) throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(keyAlgorithm);
keyPairGenerator.initialize(keySize);
return keyPairGenerator.genKeyPair();
}
private static PublicKey loadPublicKeyFromFile(String algorithm, String filePath) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
byte[] pubKeyBytes = Files.readAllBytes(Paths.get(filePath));
X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(pubKeyBytes);
return keyFactory.generatePublic(pubSpec);
}
private static PrivateKey loadPrivateKeyFromFile(String algorithm, String filePath) throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
byte[] priKeyBytes = Files.readAllBytes(Paths.get(filePath));
PKCS8EncodedKeySpec priSpec = new PKCS8EncodedKeySpec(priKeyBytes);
return keyFactory.generatePrivate(priSpec);
}
private static String bytes2Base64UTF8(byte[] bytes) {
return new String(Base64.getMimeEncoder().encode(bytes), StandardCharsets.UTF_8);
}
}
2.4 添加JWT过滤器
@Slf4j
@Component
public class AuthFilter extends OncePerRequestFilter {
private static final String TRACE_ID_KEY_IN_REQUEST = "X-B3-Traceid";
private static final String TRACE_ID_KEY_IN_LOG = "traceId";
private static final String USER_ID_KEY_IN_LOG = "userId";
private static final String USER_INFO_KEY_IN_HEADER = "Authorization";
@Autowired
private ObjectMapper objectMapper;
@Autowired
private JwtGenerator jwtGenerator;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//在日志中添加traceId和userId
String traceId = String.valueOf(request.getHeader(TRACE_ID_KEY_IN_REQUEST));
MDC.put(TRACE_ID_KEY_IN_LOG, traceId);
response.addHeader(TRACE_ID_KEY_IN_REQUEST, traceId);
// 不拦截注册页面
String uri = request.getRequestURI();
if (uri.equals("/register")) {
filterChain.doFilter(request, response);
}
//从Header中获取用户信息
String token = request.getHeader(USER_INFO_KEY_IN_HEADER);
try {
Authentication auth = jwtGenerator.parseJwt(token, Authentication.class);
SecurityContextHolder.set(auth);
} catch (JwtException e) {
log.info("{}: {}", e.getMessage(), e.getJwtMsg());
//过滤器中抛出的异常是无法被全局异常处理器捕获的!所以只能手动设置返回值,不能依赖全局异常处理器。
setAuthorizedErrorResponse(response, CommonResult.failure(e.getMessage()));
// setAuthorizedErrorResponse 方法把响应内容写入了 response 对象的 Writer 中,但没有调用 flush() 方法来刷新缓冲区。因此,如果响应内容太小(例如只有几个字符),可能无法立即返回客户端。需要在方法最后添加一句 response.flushBuffer() 来强制刷新缓冲区,保证响应内容能够立即返回客户端。
response.flushBuffer();
return;
} catch (NoAuthException e) {
log.info("{}: {}", e.getMessage(), "用户没有权限");
SecurityContextHolder.set(new Authentication(-1L));
}
//在日志中添加userId
MDC.put(USER_ID_KEY_IN_LOG, String.valueOf(SecurityContextHolder.getUserId()));
//打印HTTP请求入站日志
log.info("入站请求 {} {}, 参数: {}, 用户: {}", request.getMethod(), request.getRequestURI(), request.getQueryString(), SecurityContextHolder.getUserId());
//放行请求
filterChain.doFilter(request, response);
//清空上下文
SecurityContextHolder.remove();
}
private void setAuthorizedErrorResponse(HttpServletResponse response, CommonResult result) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().print(objectMapper.writeValueAsString(result));
}
}
OncePerRequestFilter是指对每个request请求都进行过滤,代表请求的每个页面都需要进行登录验证,除了注册页面。从前端发送的header中找到"Authorization"对应的token,对其进行解析验证,如果通过则放行request,否则报错。
为了存储用户信息,新建一个类SecurityContextHolder,JWT解析验证通过后,需要使用这个类存储用户信息,以后再需要用户信息时可以通过这个类来获取。
public class SecurityContextHolder {
private static final ThreadLocal<Authentication> HOLDER = new ThreadLocal<>();
public static void set(Authentication authentication) {
HOLDER.set(authentication);
}
public static Long getUserId() {
return HOLDER.get().getId();
}
public static void remove() {
HOLDER.remove();
}
}
使用id来代表用户信息。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Authentication {
/**
* 用户ID
*/
private Long id;
}
2.5 密码验证登录
@RequestMapping(value = "login/pwd", method = RequestMethod.POST)
@SneakyThrows(JsonProcessingException.class)
public CommonResult login(@RequestBody String json,
HttpServletResponse response) throws NotFoundException{
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(json);
String nickname = rootNode.get("nickname").asText();
String password = rootNode.get("password").asText();
UserInfoDto user = service.getUserByname(nickname);
if(user==null){
return CommonResult.failure("账号或密码错误");
}
UserInfoVo userInfoVo = userApiConverter.toUserInfoVo(user);
boolean flag = pwdLoginService.verifyPwdLogin(user, password);
String jwt = jwtGenerator.createJwt(user.getId());
response.addHeader("Authorization", jwt);
if(flag){
return CommonResult.success(userInfoVo);
}else {
return CommonResult.failure("账号或密码错误");
}
}
验证用户密码通过后需要生成jwt添加到header中,以后每次客户端发送请求都应该携带这个"Authorization"字段。