SpringBoot业务开发 09、Springboot集成token认证
@[toc]
前言
本篇博客是SpringBoot集成token认证的案例,若文章中出现相关问题,请指出!
所有博客文件目录索引:博客目录索引(持续更新)
一、引出session问题以及token鉴权
session问题
session出现的问题:
- session都是保存在内存中,认证用户增多,服务端开销明显增大。
- 扩展性,若是认证的记录保存在某台服务器内存中时,意味着用户的下次请求只能够在该服务器内存中进行认证。对于分布式应用,限制了负载局衡的能力,意味着限制了引用扩展能力。
- CSRF跨站攻击:由于session认证是基于浏览器发送请求携带指定cookie值到服务器来进行用户识别的,一旦cookie被截获或者说在其他网站点击了恶意链接用户就会很容易受到跨站请求伪造的攻击,恶意者可以通过某个用户的cookie来间接做一些额外的事情。
token鉴权机制
token的鉴权机制:http协议也是无状态的,不需要在服务端去保留用户的认证信息或者会话信息。这也就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录,为应用的扩展提供了遍历。
鉴权流程:简单来说就是服务器根据前端传来的用户名与密码生成token并返回前端,前端之后的请求都会携带该cookie来进行执行操作认证。
- 用户使用用户名密码来请求服务器。
- 服务器进行验证用户的信息。
- 服务器通过验证生成token发送给用户一个token。
- 客户端存储token,并在每次请求时附送上这个token值。
- 服务端验证token值,并返回数据。
注意:这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)
策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *
。
二、认识JWT(三部分详细构成)
2.1、JWT构成
JWT
(JSON WEB TOKEN)是由三段信息构成的,将这三段信息文本用.
链接一起就构成了Jwt字符串.
- 第一部分:头部(header)。
- 第二部分:载荷(payload),携带的信息。
- 第三部分:签证(signature)。
JWT字符串示例:JWT解析网站
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第一部分:header
包含两部分信息:①声明类型,这里是jwt。②声明加密的算法 通常直接使用 HMAC SHA256。
完整的头部:
{
'typ': 'JWT',
'alg': 'HS256'
}
对头部进行加密:该加密是可以对称解密的
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
第二部分:playload
载荷就是存放有效信息的地方。携带的信息,这些有效信息包含三个部分:
-
标准中注册的声明
-
iss: jwt签发者 sub: jwt所面向的用户 aud: 接收jwt的一方 exp: jwt的过期时间,这个过期时间必须要大于签发时间 nbf: 定义在什么时间之前,该jwt都是不可用的. iat: jwt的签发时间 jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
-
-
公共的声明:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密。
-
私有的声明:私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
定义一个playload载荷:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
进行base64加密,得到Jwt的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
第三部分:signature(签证)
签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret
第三部分组成:需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分
js加密示例:
// 将header与payload各自使用base64加密并用.来连接组合
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
// 接着将组合好之后的字符串进行加盐加密,使用HMACSHA256来进行加密
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
最终我们将header、playload以及signature各自的加密字符串使用.连接即可组成最终的JWT
。
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
2.2、客户端实际应用token
客户端在接收到token之后再次发送请求时,一般是在请求头里加入Authorization
,并加上Bearer
标注:
fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})
服务端会验证token,如果验证通过就会返回相应的资源。
2.3、JWT的大致流程
三、手写JWT
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.10</version>
</dependency>
思路:头部、playload部分进行Base64编码,对于密钥部分是将playload与盐结合并使用MD5加密
import cn.hutool.crypto.digest.MD5;
import com.alibaba.fastjson.JSONObject;
import java.util.Base64;
/**
* @ClassName JWTUtils
* @Author ChangLu
* @Date 2021/9/20 13:58
* @Description TODO
*/
public class JWTUtils {
private static String secret = "jidjiof";
public static void main(String[] args) {
JSONObject header = new JSONObject();
header.put("alg", "HS256");
header.put("typ", "jwt");
JSONObject playLoad = new JSONObject();
playLoad.put("id", "123456");
//头部与主体部分进行base64编码
String base64Header = Base64.getEncoder().encodeToString(header.toJSONString().getBytes());
String base64PlayLoad = Base64.getEncoder().encodeToString(playLoad.toJSONString().getBytes());
//组合:其中第三部分是拿playLoad的json字符串与盐合并进行MD5加密
String token = base64Header + "." + base64PlayLoad + "." + MD5.create().digestHex(playLoad.toJSONString() + secret);
System.out.println("token:" + token);
//解密
String base64PlayLoadStr = new String(Base64.getDecoder().decode(token.split("\\.")[1]));//拿到playload编码前字符串
String jwtSecret = token.split("\\.")[2];//拿取到MD5加密后密钥
//判断是否有没有中途修改
System.out.println(MD5.create().digestHex(base64PlayLoadStr + secret).equals(jwtSecret));
}
}
对于我们自己实现的话还是有许多的不足之处,例如设置有效时间、相同的内容会产生相同的token等等。
- 有效时间:可以另外在playload中设置描述有效时间的属性值。
- 相同的内容产生不同的token字符串:可以在属性中添加一个时间戳,此时我们每次生成的都会不一样。
四、实现JWT权限认证
前提准备(目录介绍与依赖引入)
引入依赖:
<!-- 引入jwt依赖 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
3.1、JwtUtil以及yml配置
application.yml
:主要配置一些token的header类型,token密钥定义
#token配置
token:
header: token # header类型
secret: 789 #token的秘钥
expireTime: 1 #token的有效时间,以天为单位,默认为1天
JwtUtil
:工具类,对第三方jwt工具类进行封装,主要用于创建、注册JWT以及获取到JWT中的键值对
package com.changlu.springbootdemo.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.changlu.springbootdemo.pojo.User;
import com.changlu.springbootdemo.pojo.response.LoginUser;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
/**
* JWT工具类
*
* @author changlu
* @date 2021/08/09 16:27
**/
@Component
@Data
@Slf4j
public class JwtUtil {
// header头部声明类型
@Value("${token.header}")
private String header;
// signature中的秘钥
@Value("${token.secret}")
private String secret;
// 过期时间
@Value("${token.expireTime}")
private static Integer expireTime;
/**
* 默认过期时间为1天
*/
private static final Integer DEFAULT_EXPIRETIME = 1;
/**
* 生成JWT token
*
* @param playLoadMap 封装包含用户信息的map
* @return
*/
public String createToken(Map<String, String> playLoadMap) {
// playload主体信息为空则不生成token
if (CollectionUtils.isEmpty(playLoadMap)) {
return null;
}
// 过期时间:若是配置文件不配置就使用默认过期时间(1天)
Calendar ca = Calendar.getInstance();
if (expireTime == null || expireTime <= 0) {
expireTime = DEFAULT_EXPIRETIME;
}
ca.add(Calendar.DATE, expireTime);
// 创建JWT的token对象
JWTCreator.Builder builder = JWT.create();
playLoadMap.forEach((k, v) -> {
builder.withClaim(k, v);
});
// 设置发布事件
builder.withIssuedAt(new Date());
// 过期时间
builder.withExpiresAt(ca.getTime());
// 签名加密
String token = builder.sign(Algorithm.HMAC256(secret));
return token;
}
/**
* 从token中获取到指定指定keyName的value值
* @param keyName 指定的keyname
* @param token token字符串
* @return 对应keyName的value值
*/
public String getTokenClaimByName(String keyName,String token){
DecodedJWT decode = JWT.decode(token);
return decode.getClaim(keyName).asString();
}
/**
* 验证JwtToken 不抛出异常说明验证通过
* @param token JwtToken数据
*/
public void verifyToken(String token)throws Exception{
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret)).build();
jwtVerifier.verify(token);
}
/**
* 生成响应对象返回给前端
* @param user pojo对象
* @param token token
* @param request 请求对象
* @return
*/
public LoginUser buildLoginUser(User user, String token, HttpServletRequest request){
// 过期的毫秒数
Long expireTimeMillis = expireTime * 24 * 60 * 60 *1000L;
// LoginUser作为登陆用户信息实体类(用于返回给前台的相关信息)
LoginUser loginUser = new LoginUser();
user.setPassword(null);
loginUser.setToken(token);// 登陆凭证
loginUser.setUser(user);// 用户信息
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(expireTimeMillis);
loginUser.setIpAddr(request.getRemoteAddr());
return loginUser;
}
}
3.2、pojo(User)与vo(UserRequest以及LoginUser)
pojo
User.java
:用于描述数据库中的实体类,ORM映射模型
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @ClassName User
* @Author ChangLu
* @Date 2021/7/28 23:15
* @Description TODO
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private Integer id;
private String username;
private String password;
}
vo
UserRequest
以及LoginUser
:前者是用于接收请求体参数的,后者则是用于返回给前端的响应实体类(描述用户的登录状态)。
UserRequest.java
:
import com.changlu.springbootdemo.pojo.User;
import java.io.Serializable;
/**
* @ClassName UserReuqest
* @Author ChangLu
* @Date 2021/8/15 18:29
* @Description TODO
*/
//直接继承User实体类得到其属性,之后也可以继续进行扩展
public class UserRequest extends User implements Serializable {
private static final long serialVersionUID = -7849794470884667710L;
}
LoginUser.java
:
import com.changlu.springbootdemo.pojo.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @ClassName LoginUser
* @Author ChangLu
* @Date 2021/8/15 17:53
* @Description TODO
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser {
// 用户唯一标识
private String token;
// 登陆时间
private Long loginTime;
// 过期时间
private Long expireTime;
// 登陆IP地址
private String ipAddr;
// 登录地点
private String loginLocation;
// 登陆的用户
private User user;
}
3.3、JWT拦截器实现与注册拦截器
JwtInterceptor.java
首先需要实现一个拦截器,其主要目的是对所有的请求进行拦截校验,当一个用户登录好之后得到token,之后的请求都会携带这个token,这个token就是其自身的凭证,在拦截器中对token进行校验:
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.SignatureGenerationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.changlu.springbootdemo.enums.CommonExceptionEnum;
import com.changlu.springbootdemo.exception.OwnException;
import com.changlu.springbootdemo.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @ClassName JwtInterceptor
* @Author ChangLu
* @Date 2021/8/15 18:05
* @Description TODO
*/
@Component
@Slf4j
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取token值
String token = request.getHeader("Authorization");
try {
//验证token是否有误,若是验证失败会相应抛出指定的异常
jwtUtil.verifyToken(token);
return true;
}catch (SignatureGenerationException signatureGenerationException){
throw new OwnException(CommonExceptionEnum.TOKEN_VERITY_GENERATED);
}catch (TokenExpiredException tokenExpiredException){
throw new OwnException(CommonExceptionEnum.TOKEN_VERITY_EXPIRED);
}catch (AlgorithmMismatchException algorithmMismatchException){
throw new OwnException(CommonExceptionEnum.TOKEN_VERITY_ALGORITHM_NOT_MATCH);
}
}
}
//token验证异常枚举实体类
TOKEN_VERITY_GENERATED(3001,"token使用算法签名时无法生成令牌的签名"),
TOKEN_VERITY_EXPIRED(3002,"token校验已过期"),
TOKEN_VERITY_ALGORITHM_NOT_MATCH(3003,"token校验加密方法无效");
拦截器注册
当我们定义好拦截器以后,就需要将其进行注册到webmvc中,只要注册了之后才会使用该拦截器进行拦截校验。
/**
* @ClassName WebSecurityConfig
* @Author ChangLu
* @Date 2021/8/15 18:24
* @Description TODO
*/
@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截所有请求,除了登陆请求(登陆请求我们不需要进行拦截,其是产生token的源头)
registry.addInterceptor(jwtInterceptor).addPathPatterns("/**").excludePathPatterns("/user/login");
}
}
3.4、测试token
我们来模拟三次请求来测试该token工具类是否有效:
- 登陆:测试其是否能够产生token并返回给前台。
- 删除用户:该请求携带指定token请求头,来看看是否能够通过权限校验。
- 查询token中的id:该请求是用于获取token字符串的载荷携带的信息,将其token字符串内部的id值进行返回。
import com.changlu.springbootdemo.common.ResultBody;
import com.changlu.springbootdemo.enums.CommonExceptionEnum;
import com.changlu.springbootdemo.exception.OwnException;
import com.changlu.springbootdemo.pojo.User;
import com.changlu.springbootdemo.pojo.response.LoginUser;
import com.changlu.springbootdemo.pojo.request.UserRequest;
import com.changlu.springbootdemo.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
/**
* @ClassName UserController
* @Author ChangLu
* @Date 2021/7/28 23:14
* @Description TODO
*/
@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {
@Autowired
private JwtUtil jwtUtil;
/**
* 登陆
*
* @param request
* @param userRequest
* @return
*/
@PostMapping("/login")
public ResultBody userLogin(HttpServletRequest request, @RequestBody UserRequest userRequest) {
User user = new User(1111, "changlu", "123456");
if (!("changlu".equals(userRequest.getUsername()) && "123456".equals(userRequest.getPassword()))) {
// 登陆不成功
throw new OwnException(CommonExceptionEnum.LOGIN_ERROR);
} else {
// 登陆成功
Map<String, String> playLoadMap = new HashMap<>(1);
playLoadMap.put("id", user.getId().toString());
String token = jwtUtil.createToken(playLoadMap);
LoginUser loginUser = jwtUtil.buildLoginUser(user, token, request);
return ResultBody.success(loginUser);
}
}
/**
* 删除指定id用户
*
* @param id 指定用户的id
* @return
*/
@DeleteMapping("/{id}")
public ResultBody queryList(@PathVariable("id") Integer id) {
return ResultBody.success("删除id为" + id + "的用户成功!");
}
/**
* 查询token中的id
* @param request
* @return
*/
@GetMapping("/")
public ResultBody queryLoginId(HttpServletRequest request) {
String token = request.getHeader("Authorization");
String id = jwtUtil.getTokenClaimByName("id", token);
return ResultBody.success("取到token中存储的id值为:" + id);
}
}
说明:对于之后的两次请求都要携带指定的header键值对,也就是对应的token,可从第一次请求返回值中获取!
五、JWT如何实现注销
-
浏览器cookie清除(但是服务器还是存在)
-
建议将时间设置稍微短一点
使用jwt就不需要使用到redis缓存,对于的值需要进行缓存。
JWT最好设置时间为1天或者30分钟。
参考文章
[1]. 什么是 JWT – JSON WEB TOKEN:JWT的基础知识概念理解,强推。本笔记大部分内容摘自该文章
[2]. json web token(JWT)基本原理及Springboot集成:程序参考(主)
[3]. JWT的Java使用 (JJWT):(辅)
安全:
- 点赞
- 收藏
- 关注作者
评论(0)