实践 Session、Cookie、JWT、Token、Sa-Token
2024-11-21 09:25:53 # Technical # SpringMVC

最近常看到一些关于 JWT、Session、Toen 提问的帖子,他们之间的关系也没了解过,因为一上手就是用的 JWT 和 Token,感觉 Session 和 Cookie 是很古老的东西了,所以就从实战的角度理解下他们的关系

验证码获取接口

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
@GetMapping("/captcha/{login:.+}")
public void registerCaptcha(HttpServletResponse response, @PathVariable("login") String login) {
try {
response.setContentType("image/png");
OutputStream oStream = response.getOutputStream();
Object[] objects = getGraphCaptcha();
String captcha = (String) objects[0];
// 将账号与验证码的映射关系存起来,后面验证(需要加上有效期,1min,2min)
this.captchaMap.put(login, captcha);
BufferedImage image = (BufferedImage) objects[1];
ImageIO.write(image, "png", oStream);
oStream.flush();
oStream.close();
} catch (Exception e) {
log.error("验证码获取失败");
}
}

private Object[] getGraphCaptcha() throws NoSuchAlgorithmException {
char[] codeSequence = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',
'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
'X', 'Y', 'Z', '1', '2', '3', '4', '5', '6', '7', '8', '9'};
int width = 110;
int height = 36;
int lines = 8;
int red;
int green;
int blue;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();

// 设置背景色 灰十九
g.setColor(new Color(232, 232, 232));
// 画背景
g.fillRect(0, 0, width, height);
// 填充指定的矩形。使用图形上下文的当前颜色填充该矩形
g.setFont(new Font("黑体", Font.BOLD, 25));
StringBuilder sb = new StringBuilder();

SecureRandom r = SecureRandom.getInstanceStrong();
// 字体
for (int i = 0; i < 4; i++) {
String strRand = String.valueOf(codeSequence[r.nextInt(codeSequence.length)]);
sb.append(strRand);
g.setColor(new Color(0, 0, 0));
g.drawString(strRand, i * 25, 20 + r.nextInt(10));
}

// 干扰线
for (int i = 0; i < lines; i++) {
// 设置随机开始和结束坐标
// x坐标开始
int xs = 20 + r.nextInt(width);
// y坐标开始
int ys = 20 + r.nextInt(height);
// x坐标结束
int xe = xs + r.nextInt(width);
// y坐标结束
int ye = ys + r.nextInt(height);

// 产生随机的颜色值,让输出的每个干扰线的颜色值都将不同。
red = r.nextInt(255);
green = r.nextInt(255);
blue = r.nextInt(255);
g.setColor(new Color(red, green, blue));
g.drawLine(xs, ys, xe, ye);
}

// 类似于流中的close()带动flush()---把数据刷到img对象当中
g.dispose();

return new Object[]{sb.toString(), image};
}

登录接口

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
@GetMapping("/login")
public Object login(HttpServletRequest request, HttpSession session, HttpServletResponse response) {
JSONObject jsonObject = new JSONObject();
String username = request.getParameter("username");
String password = request.getParameter("password");
String captcha = request.getParameter("captcha");

// 验证必填信息
if (StringUtils.isBlank(username) || StringUtils.isBlank(password) || StringUtils.isBlank(captcha)) {
jsonObject.put("code", -1);
jsonObject.put("msg", "用户名或密码或验证码不能为空");
return jsonObject;
}

// 验证验证码
if (!captchaMap.containsKey(username) || !captchaMap.get(username).equals(captcha)) {
jsonObject.put("code", -1);
jsonObject.put("msg", "验证码错误");
return jsonObject;
}

// 验证账号密码
// ...
// 模拟获取到用户信息
User user = new User(1L, "venom", "123456");

// 移除验证码
this.captchaMap.remove(username);

// 将用户信息放入 request 作用域
session.setAttribute(String.valueOf(user.getUserId()), user);
// 生成 cookie
Cookie cookie = new Cookie("userId", String.valueOf(user.getUserId()));
// 设置 cookie 存活时间
cookie.setMaxAge(7 * 24 * 60 * 60);
// 将 cookie 放入相应头
response.addCookie(cookie);

jsonObject.put("code", 0);
jsonObject.put("msg", "登录成功");
return jsonObject;
}

定义拦截器

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
@Slf4j
public class SessionInterceptor extends HandlerInterceptorAdapter {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.info("拦截请求:{}", uri);
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
log.info("cookie:{}", cookie.getName());
if (Objects.equals(cookie.getName(), "userId")) {
String userId = cookie.getValue();
User user = (User) request.getSession().getAttribute(userId);
if (Objects.isNull(user)) {
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", 401);
jsonObject.put("msg", "重新登录");
returnJson(response, jsonObject);
return false;
}
log.info("{}:已登录", user.getUserName());
return true;
}
}
}
JSONObject jsonObject = new JSONObject();
jsonObject.put("code", 401);
jsonObject.put("msg", "未登录");
returnJson(response, jsonObject);
return false;
}

private void returnJson(HttpServletResponse response, JSONObject jsonObject) {
//将实体对象转换为JSON Object转换
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
try (PrintWriter out = response.getWriter()) {
out.append(jsonObject.toJSONString());
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}

注册拦截器

1
2
3
4
5
6
7
8
9
10
@Configuration
public class WebMvcAuthConfig extends WebMvcConfigurerAdapter {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SessionInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login", "/captcha/**");
}
}

WebMvcConfigurerAdapter 在 Spring 5.0 版本中被弃用,建议使用新的方式来进行 Spring MVC 的配置,可以实现 WebMvcConfigurer 接口并重写其中的方法

普通的业务接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@GetMapping("/mem/infos")
public String memInfo() {
StringBuilder sb = new StringBuilder();
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage();
sb.append("<h3>heap堆信息</h3> ").append("\n");
sb.append("<h3>初始(M):").append(heapMemoryUsage.getInit() / MB).append("</h3>").append("\n");
sb.append("<h3>最大(上限)(M):").append(heapMemoryUsage.getMax() / MB).append("</h3>").append("\n");
sb.append("<h3>当前(已使用)(M):").append(heapMemoryUsage.getUsed() / MB).append("</h3>").append("\n");
sb.append("<h3>提交的内存(已申请)(M):").append(heapMemoryUsage.getCommitted() / MB).append("</h3>").append("\n");
sb.append("<h3>使用率:").append(heapMemoryUsage.getUsed() * 100 / heapMemoryUsage.getCommitted()).append("%").append("</h3>").append("\n");
sb.append("<h3>non-heap非堆信息</h3> ").append("\n");
MemoryUsage nonHeapMemoryUsage = memoryMXBean.getNonHeapMemoryUsage();
sb.append("<h3>初始(M):").append(nonHeapMemoryUsage.getInit() / MB).append("</h3>").append("\n");
sb.append("<h3>最大(上限)(M):").append(nonHeapMemoryUsage.getMax() / MB).append("</h3>").append("\n");
sb.append("<h3>当前(已使用)(M):").append(nonHeapMemoryUsage.getUsed() / MB).append("</h3>").append("\n");
sb.append("<h3>提交的内存(已申请)(M):").append(nonHeapMemoryUsage.getCommitted() / MB).append("</h3>").append("\n");
sb.append("<h3>使用率:").append(nonHeapMemoryUsage.getUsed() * 100 / nonHeapMemoryUsage.getCommitted()).append("%").append("</h3>").append("\n");
return sb.toString();
}

模拟流程

不登录请求

先不登录,直接请求业务接口

401-未登录

获取验证码

/captcha/venom

登录

/login?username=venom&password=123456&captcha=I6IN

查看请求详情可以看到返回的 cookie

请求详情

重新请求

/mem/infos

重新请求响应成功,并且可以在请求详情中看到前面登录返回的 cookie

请求详情

JSESSIONID

可以发现上面除了自己生成的 cookie 外,还有一个名为 JSESSIONID 的 cookie

对于 Tomcat 容器来说,当服务端的 session 被创建时,Response 中自动添加了一个 Cookie:JSESSIONID:xxxx,在后续的请求中,浏览器也是自动的带上了这个 Cookie,服务端根据 Cookie 中的 JSESSIONID 取到了对应的 session。这验证了一开始的说法,客户端服务端是通过 JSESSIONID 进行交互的,并且,添加和携带 key 为 JSESSIONID 的 Cookie 都是 Tomcat 和浏览器自动帮助我们完成的,这很关键

Session 的持久化

默认情况下,SpringBoot 是不会持久化 Session 的,这时重启服务后,session 都会失效,所以需要持久化 Session

session 的持久化可以使用 Mysql 或者 Redis,这里示例为了方便,就持久化到本地

增加配置

1
server.servlet.session.persistent = true

注意:因为持久化到本地需要使用 Java 的序列化与反序列化方案,所以这里的 User 类需要实现 Serializable 接口并定义 serialVersionUID

JWT + Token 实现鉴权认证

整理上的登录验证流程与前面大差不差,主要是不使用 Session 和 Cookie,使用 token 替代

登录接口

登录接口在鉴权成功后,使用 JWT 生成 Token,将 Token 缓存到 Redis 中并返回

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
/**
* JWT 生成 Token
*/
public static String createToken(Long userId) {
// header Map
Map<String, Object> map = new HashMap<>();
map.put("alg", "HS256");
map.put("typ", "JWT");

// build token
return JWT.create().withHeader(map) // header
.withClaim("iss", "Service") // payload
.withClaim("aud", "APP")
.withClaim("user_id", null == userId ? null : userId.toString())
.withIssuedAt(new Date()) // sign time
// .withExpiresAt(expiresDate) // expire time 不启用, 过期时间交由redis管理
.sign(Algorithm.HMAC256(SECRET)); // signature
}

/**
* 缓存 token 与 user 映射
*/
public static void store(String token, User user) {
StringRedisTemplate redisTemplate = SpringContextHolder.getBean(StringRedisTemplate.class);
redisTemplate.opsForValue().set(token, JsonUtil.toJson(user));
redisTemplate.expire(key, 7200000L, TimeUnit.MILLISECONDS);
}

拦截器校验 Token

与前面不同的是,token 是放到请求头里,然后通过 redis 获取 token 与之对应的 user

自定义注解获取当前用户

自定义注解

1
2
3
4
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {
}

通过实现 SpringMVC 的 HandlerMethodArgumentResolver 接口,获取当前请求用户并解析到接口中

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
@slf4j
@Component
public class CurrentUserMethodArgumentResolver implements HandlerMethodArgumentResolver {

private final Logger logger = LoggerFactory.getLogger(this.getClass());

@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public boolean supportsParameter(MethodParameter parameter) {
// 如果有 CurrentUser 注解则支持解析
if (parameter.hasParameterAnnotation(CurrentUser.class)) {
return true;
}
return false;
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
// 取出鉴权时存入的登录用户Id
String reqToken = webRequest.getHeader(ICommonConstant.TokenKey.AUTHORIZATION);
String token = webRequest.getHeader(ICommonConstant.TokenKey.TOKEN);

log.info("网关传来的token:{}, 用token作为key:{}", reqToken, token);

// 如果请求头中没有token,则从参数中获取,可能是编码格式转变了,token中的加号(+)莫名奇妙的转换为了空格
if (StringUtils.isBlank(reqToken)) {
reqToken = token;
}

// 判断
if (StringUtils.isBlank(reqToken)) {
throw new MissingServletRequestPartException("reqToken in Header can't be null");
}

if(reqToken.contains(ICommonConstant.TokenKey.BEARER)) {
reqToken = reqToken.replace(ICommonConstant.TokenKey.BEARER, "").trim();
}

String userInfoJson = stringRedisTemplate.opsForValue().get(reqToken);

if (StringUtils.isBlank(userInfoJson)) {
throw new MissingServletRequestPartException("UserInfo in Cache can't be null");
}

UserInfo userInfo =new UserInfo();
try {
if(userInfoJson.indexOf("{") > 0) {
userInfoJson = userInfoJson.substring(userInfoJson.indexOf("{"), userInfoJson.length());
}
userInfo = JSONObject.parseObject(userInfoJson, UserInfo.class);
} catch (Exception e) {
throw new MissingServletRequestPartException("JSON can't Transformation be UserInfo");
}
return userInfo;
}
}

工具类获取当前登录用户

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
74
75
76
77
78
79
80
@Slf4j
@Component
public final class AccountUtil {

private static StringRedisTemplate redisTemplate;

@Resource
private StringRedisTemplate stringRedisTemplate;

@PostConstruct
public void init() {
redisTemplate = stringRedisTemplate;
}


/**
* Description: 从请求获取当前用户
*/
public static UserInfoVo getCurrUser(HttpServletRequest request) {
String token = request.getHeader(TokenKey.TOKEN);
return getCurrUser(token);
}

/**
* Description: 获取当前登录用户
*/
public static UserInfoVo getCurrUser() {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
return getCurrUser(request);
}

/**
* Description: 根据token获取用户信息
*/
public static UserInfoVo getCurrUser(String token) {
if (StringUtils.isBlank(token)) {
//throw new BusinessException(ReturnCodeEnum.THIRD_TOKEN_ERROR, "token in Header can't be null");
throw new SystemException("token in Header can't be null");
}

if (token.contains(TokenKey.BEARER)) {
token = token.replace(TokenKey.BEARER, "").trim();
}

String userInfoJson = redisTemplate.opsForValue().get(token);

if (StringUtils.isBlank(userInfoJson)) {
//throw new BusinessException(ReturnCodeEnum.THIRD_TOKEN_ERROR, "UserInfo in Cache can't be null");
throw new SystemException("token in Header can't be null");
}

UserInfoVo userInfo;
try {
// 需要截取字符串
if (userInfoJson.indexOf("{") > 0) {
userInfoJson = userInfoJson.substring(userInfoJson.indexOf("{"));
}
userInfo = JSON.parseObject(userInfoJson, UserInfoVo.class);
} catch (Exception e) {
//throw new BusinessException(ReturnCodeEnum.THIRD_TOKEN_ERROR, "JSON can't Transformation be UserInfo");
throw new SystemException("token in Header can't be null");
}
return userInfo;
}

/**
* Description: 判断是否登录
*/
public static boolean isLogin(HttpServletRequest request) throws BusinessException {
Object user = getCurrUser(request);
return user != null;
}

public interface TokenKey {
String AUTHORIZATION = "Authorization";
String TOKEN = "token";
String LOG_ID = "logId";
String BEARER = "Bearer ";
}
}

Sa-Token

Sa-Token 自称可能是史上功能最全的 Java 权限认证框架!目前已集成——登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、单点登录、OAuth2.0、踢人下线、Redis 集成、前后台分离、记住我模式、模拟他人账号、临时身份切换、账号封禁、多账号认证体系、注解式鉴权、路由拦截式鉴权、花式 token 生成、自动续签、同端互斥登录、会话治理、密码加密、jwt 集成、Spring 集成、WebFlux 集成…

Sa-Token 实现登录

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
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public LoginVo login(String accountName, String password, String captcha) throws BusinessException {
if (StringUtils.isBlank(accountName) || StringUtils.isBlank(password)) {
throw new BusinessException(ResultCodeEnum.TOKEN_ERROR, SaTokenEnum.USER_PASSWORD_EMPTY.message);
}

// 忽略前面的校验过程...
Account account = new Account(1L, "venom", "123456");

// 如果引入了sa-token-dao-redis-jackson,就会把登录生成的 token 保存到 redis 上面,否则保存在 jvm 本地
// 账号 id,最好不要设置对象,对象在 redis 当中的序列化不是 json 也不是 java 序列化
StpUtil.login(account.getId());
LoginUser loginUser = new LoginUser()
.setAccountName(accountName)
.setUserName(account.getUserName())
.setId(account.getId())
.setOwnerProjectId(account.getOwnerProjectId())
.setVirtual(Integer.valueOf(account.getVirtual()));
// sa-token 保存用户信息到 redis
StpUtil.getTokenSession().set(RequestConst.LOGIN_USER, loginUser);
return new LoginVo()
.setAccountName(accountName)
.setToken(StpUtil.getTokenValue())
.setLoginId(account.getId())
.setUserName(account.getUserName());
}