06 增加用户上下文处理器
06 增加用户上下文处理器
前言
上一篇文章,我们已经把秒杀系统的登录接口搞定了。
而我们的商品秒杀接口,是需要在用户登录之后,发起请求的。
如何实现用户登录之后,请求商品秒杀接口时,可以自动获取到用户信息呢?
如果验证用户有没有登录呢?
带着这两个问题,开始了今天的文章之旅。
1 如何验证用户登录?
上一篇文章中,用户调用/login登录接口之后,如果登录成功,是会返回一个token信息的。
这个token信息,会缓存到用户浏览器中。
同时该token信息也会保存一份到Redis中。
key是username,value是token,并且设置了一个过期时间。
有了这些东西之后,验证是否用户登录就非常好办了。
我们只需要将浏览器中的token参数,传入到商品秒杀接口中,然后校验一下这个token是否在Redis是否存在,或者是否已过期了。
这样就能校验用户是否成功登录了。
这个token是由一定算法生成的,没有密钥,前端用户根本没有办法生成或者篡改,所以是比较安全的。
接下来,还有几个问题:
- token参数如何传入商品秒杀接口,是通过request param吗?这样做可以,但是不太通用,因为其他的接口可能也会验证用户登录,如果每个接口都要传入这个参数,就非常麻烦。
- 用户是否登录的校验,放在哪里比较合适。在商品秒杀接口中做吗?显然不太合适。
- 用户的上下文如何处理?
我们在下面的文章中,会逐一解决这些问题。
2 通过Header传入token
我们之前说过,在每个接口的request param中传入token参数,可以,但不太通用。
那么,怎么样传入这个参数,才更通用呢?
答:通过Header传入token。
前端页面在请求服务器端接口时,有个统一的处理器,自动从浏览器缓存中获取token信息,然后添加到接口的header中。
这样业务接口,就无需关注token参数了。
当然为了这个名称更通用,我们将用户验证信息传入了Authorization参数中,这个字段主要是为了做校验和认证的。
Authorization是由token和其他的内容组成。
在我们的susan-common模块下的TokenUtil类的getTokenForAuthorization方法,就是从Authorization参数中解析出token信息的。
具体代码如下:
package cn.net.susan.util;
import cn.net.susan.constant.NumberConstant;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
/**
* token处理工具
*
* @author 苏三,该项目是知识星球:java突击队 的内部项目
* @date 2024/1/12 下午1:01
*/
public abstract class TokenUtil {
private static final String AUTHORIZATION_PREFIX = "Basic";
private static final String AUTHORIZATION_SEPARATE = "@";
private TokenUtil() {
}
/**
* 从authorization中解析token
* <p>
* authorization字符串是下面这样的:
* Basic eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJzdXNhbiIsImV4cCI6MTcwNTAzOTA3N30.DZV6CZYGla74CZaXU1sqnX9R_x5YxfTM-DWObURn3Uhr1E88XsOxOz8F_MDfh8AaVFm87zlGXAENC8soZNz0Qw
*
* @param request 用户请求
* @return token
*/
public static String getTokenForAuthorization(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (!StringUtils.hasLength(authorization)
|| !authorization.contains(AUTHORIZATION_PREFIX)
|| !authorization.contains(AUTHORIZATION_SEPARATE)) {
return null;
}
String[] values = authorization.split(AUTHORIZATION_SEPARATE);
if (values.length != NumberConstant.NUMBER_2) {
return null;
}
return values[1];
}
}
3 用户登录验证过滤器
我们需要有一个地方统一处理器,在业务接口请求时,先调用TokenUtil类的getTokenForAuthorization方法,获取token。
然后做用户登录校验。
最后将用户新放到用户上下文当中。
在seckill-business模块的下创建filter包,在该包下创建JwtTokenFilter类:
package cn.net.susan.seckill.business.filter;
import cn.net.susan.helper.UserTokenHelper;
import cn.net.susan.seckill.business.context.UserContext;
import cn.net.susan.seckill.business.entity.user.UserEntity;
import cn.net.susan.util.AssertUtil;
import cn.net.susan.util.RedisUtil;
import cn.net.susan.util.SpringUtil;
import cn.net.susan.util.TokenUtil;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.text.StringEscapeUtils;
import org.springframework.util.StringUtils;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* token过滤器
*
* @author 苏三,该项目是知识星球:java突击队 的内部项目
* @date 2024/6/26 下午4:45
*/
@Slf4j
public class JwtTokenFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String token = TokenUtil.getTokenForAuthorization(httpServletRequest);
if (!StringUtils.hasLength(token)) {
filterChain.doFilter(httpServletRequest, servletResponse);
return;
}
UserTokenHelper userTokenHelper = SpringUtil.getBean(UserTokenHelper.class);
RedisUtil redisUtil = SpringUtil.getBean(RedisUtil.class);
String usernameFromToken = userTokenHelper.getUsernameFromToken(token);
AssertUtil.isTrue(StringUtils.hasLength(usernameFromToken), "token不存在或者已过期");
String redisToken = redisUtil.get(userTokenHelper.getTokenKey(usernameFromToken));
AssertUtil.isTrue(StringUtils.hasLength(redisToken), "请先登录");
redisToken = redisToken.replace("\"", "").trim();
AssertUtil.isTrue(token.equals(redisToken), "token错误");
String userJson = redisUtil.get(userTokenHelper.getUserKey(usernameFromToken));
UserEntity userEntity = parseUserEntity(userJson);
try {
UserContext.setCurrentUser(userEntity);
filterChain.doFilter(httpServletRequest, servletResponse);
} finally {
UserContext.remove();
}
}
private String parseUserJson(String userJson) {
//去掉多余的双引号
return userJson.substring(1).substring(0, userJson.length() - 1);
}
private UserEntity parseUserEntity(String userJson) {
String json = parseUserJson(userJson);
json = StringEscapeUtils.unescapeJava(json);
json = json.substring(0, json.length() - 1);
return JSON.parseObject(json, UserEntity.class);
}
}
这个filter的doFilter方法在请求接口时会自动执行。
该方法的逻辑不复杂:
- 从header中解析出token。
- 如果token为空,则直接返回。
- 如果token不为空,则从JWT的token中解析出username。
- 根据username获取Redis中的token,判断跟用户传入的token是否相等,如果不相等则抛异常。
- 根据username从Redis中获取用户字符串。
- 反序列化用户字符串成用户实体,放到用户上下文中。
- 请求真正的业务接口。
- 释放用户上下文。
我们在mall-common模块的util包下新加了一个SpringUtil类,用来在filter中获取bean信息的。
在filter中没有办法通过 @Autowired或者@Resouce注入bean实例,因为它先执行。
但我们可以直接从Spring容器中获取bean实例,因此提供了这样一个工具,具体代码如下:
package cn.net.susan.util;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* @author 苏三,该项目是知识星球:java突击队 的内部项目
* @date 2024/6/26 下午5:56
*/
@Component
public class SpringUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext)
throws BeansException {
if (SpringUtil.applicationContext == null) {
SpringUtil.applicationContext = applicationContext;
}
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
public static Object getBean(String name) {
return getApplicationContext().getBean(name);
}
public static <T> T getBean(Class<T> clazz) {
return getApplicationContext().getBean(clazz);
}
public static <T> T getBean(String name, Class<T> clazz) {
return getApplicationContext().getBean(name, clazz);
}
}
然后将mall-business模块的TokenHelper类中的getUsernameFromToken和getClaimsFromToken方法,迁移到了mall-common模块的UserTokenHelper类中。
将UserTokenHelper类中getTokenKey和getUserKey方法,访问权限改成public,方便在秒杀系统中调用。
需要注意的是:TokenHelper类继承了UserTokenHelper类,因此该类也可以使用这两个方法。
4 用户上下文
我们还需要增加一个用户上下文类,以后可以从内存中直接获取当前登录的用户信息。
这些我们摒弃了Spring Security框架中一些复制的逻辑,实现一个简单的用户上下文即可,毕竟秒杀系统的接口不多。
在seckill-business模块下创建context包,在该包下创建UserContext类,该类的具体代码如下:
package cn.net.susan.seckill.business.context;
import cn.net.susan.seckill.business.entity.user.UserEntity;
import com.alibaba.ttl.TransmittableThreadLocal;
/**
* 用户上下文
*
* @author 苏三,该项目是知识星球:java突击队 的内部项目
* @date 2024/1/9 下午4:32
*/
public class UserContext {
private static final TransmittableThreadLocal<UserEntity> THREAD_LOCAL = new TransmittableThreadLocal<>();
/**
* 获取当前用户信息
*
* @return 当前用户信息
*/
public static UserEntity getCurrentUser() {
return THREAD_LOCAL.get();
}
/**
* 设置当前用户信息
*
* @param userEntity 当前用户信息
*/
public static void setCurrentUser(UserEntity userEntity) {
THREAD_LOCAL.set(userEntity);
}
/**
* 清空当前用户信息
*/
public static void remove() {
THREAD_LOCAL.remove();
}
}
使用了TransmittableThreadLocal保存用户信息,相较于普通的ThreadLocal,TransmittableThreadLocal在线程池中也可以传递用户信息。
在后面的业务接口中,可以直接通过UserContext.getCurrentUser();方法来获取当前登录的用户信息,非常方便。
UserEntity是我们在seckill-business模块的entity下的user包下,新加的一个类:
package cn.net.susan.seckill.business.entity.user;
import cn.net.susan.entity.auth.AuthUserEntity;
import lombok.Data;
/**
* 用户信息
*
* @author 苏三,该项目是知识星球:java突击队 的内部项目
* @date 2024/6/26 下午6:24
*/
@Data
public class UserEntity extends AuthUserEntity {
private Long id;
}
这个类继承了mall-common中的AuthUserEntity类,增加了用户id字段,方便后面的业务操作。
不用通过username再重新查一次数据库。
5 测试
在测试之前,我们先要调整一下服务的端口号,否则会有问题。
因为之前susan_mall_web和seckill-api服务使用相同的8013端口号。
现在将seckill-api模块的application.yml中的port改成8015,同时将seckill-job模块的application.yml中的port改成8016。
接下来,正式开始测试。
启动seckill-api、mall-mgt、susan_mall_web项目。
先访问后台登录页面:http://localhost:8013/login,输入用户名、密码和验证码,登录成功之后,在application中获取token信息。
在应用 》Cookie 》ELADMIN-TOEKN 的值,就是我们所需要的token。
然后使用post访问seckill-api服务的test接口:http://localhost:8015/test
目前这个接口是get请求方式。
在header中输入上面登录后的token,放到Authorization字段中,前缀是Basic@,完整的值比如:Basic@eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTcxOTQ3NDU1NH0.2rSyhq4dqJFczWDY3RzmJty1YAiQ_ZfdvegMgb1Vih2kq0J0Vd9tQ0WgsSTU_LnC2v_O-9JtpRZtyoQODzh73A
之后,点击send按钮,请求改接口。
我们debug之前写的JwtTokenFilter:
会发现代码已经成功走到了设置用户上下文之后。
然后接口返回success:
说明我们的token的校验,将用户信息自动放到用户上下文中,这样功能都OK了。