微信公众号:差点儿码不动
关注点击菜单栏“Java进阶”, 重磅干货,第一 时间到手!🌹
如果觉得文章对你有帮助,欢迎大家关注,点赞,转发😉
前言
最近接了一个小项目,需要分为前后台两个部分,前台已小程序的方式嵌入,后台有一个简单的管理界面,由于对权限要求简单,不需要复杂的权限逻辑,所以不想引入类似spring security 或者 Shiro 这样重量级的框架。于是决定手写一个简单的登录。
基础框架的的选择
因为项目功能非常简单,且后期可扩展性不强,前后端分离,基础框架就简单的使用了一个 springboot、提供最基础的resful接口,没有使用微服务架构。使用springCloud的话也类似
登录的核心问题
- 接口的拦截与白名单控制
- 登录信息在不同机器上的共享方式
- 怎么在程序的各个位置获取到登录用户信息、鉴权
- IP控制及限制单用户登录的问题
登录信息共享方式
- session+cookie共享方式
- 生成token
- JWT 共享
session+cookie的共享方案是传统的方案机制、需要引入springsession、spring-security等依赖,这里就不做了
下面 token及JWT类似,都是根据用户信息生成一个字符串传个前端,前端在请求的时候需要携带该token,不同点是JWT本身携带用户信息及过期时间,且JWT本身是一个无状态的,所以相对于token来说优点是无需保存用户信息,而token方式需要保存在服务端,通常在redis中,JWT只需要验签及获取JWT中的用户信息即可。
缺点是,JWT存在过期时间,续期需要更换整个token,这时需要前端配合更新。另外,由于JWT的无状态性,导致了登出后token在有效期内依旧是有效的,这种方式存在一定问题,所以很多文章中上有通过redis等方式给JWT赋予了状态,个人认为,这其实是违背了JWT的原意。所以我认为JWT更适合于一次性验证的业务流程,而不是用作登录会话的管理,因为用户登录行为本身就是有状态的,与JWT的无状态冲突。 所以我在这里选择直接生成token的形式
登录的接口拦截方案
在springboot上,可选择的拦截方案有三种:
- 通过WebFilter进行拦截
- 通过Intercepter进行拦截
- 通过aop进行拦截+自定义注解
现在此进行一个记录,这里统一使用head添加token作为
通过WebFilter进行拦截
1. 添加Filter
public class LoginFilter implements Filter {
private final Loginservice loginservice;
private final LoginProperties loginProperties;
public LoginFilter(Loginservice loginservice, LoginProperties loginProperties) {
this.loginservice = loginservice;
this.loginProperties = loginProperties;
}
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
// 过滤路径
String requestURI = httpServletRequest.getRequestURI();
if (!loginProperties.getFilterExcludeUrl().contains(requestURI)) {
// 获取token
String token = httpServletRequest.getHeader(Constant.TOKEN_HEADER_NAME);
// FIXME 通过redis进行token校验
// FIXME token续期逻辑
if(loginservice.checkUserToken()){
BaseResponse resRes=BaseResponse.error(ErrorCode.NOLOGIN);
//FIXME resRes 通过response返回
return;
}
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
复制代码
2.添加配置项
将拦截器配置到工程中
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginProperties loginProperties;
@Autowired
private LoginService loginservice;
/**
* 添加登录过滤器
*/
@Bean
public FilterRegistrationBean<Filter> loginFilterRegistration() {
// 注册LoginFilter
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new LoginFilter(loginservice, loginProperties));
// 设置名称
registrationBean.setName("loginFilter");
// 设置拦截路径
registrationBean.addUrlPatterns(loginProperties.getFilterIncludeUrl().toArray(new String[0]));
// 指定顺序,数字越小越靠前
registrationBean.setOrder(1);
return registrationBean;
}
}
复制代码
通过Interceptor进行拦截
其原理与Filter类似,区别是Interceptor属于SpringMvc的拦截器,而Filter属于Sevlet的拦截器,实现方法如下:
@Component
public class LoginInterception implements HandlerInterceptor {
@Autowired
private LoginService loginservice;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取token
String token = request.getHeader(Constant.TOKEN_HEADER_NAME);
loginservice.checkUserToken();
// 放行
return true;
}
}
复制代码
同样:配置配置项
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private LoginProperties loginProperties;
@Resource
private LoginInterception loginInterception;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterception)
.addPathPatterns(loginProperties.getInterceptorIncludeUrl())
.excludePathPatterns(loginProperties.getInterceptorExcludeUrl());
}
}
复制代码
aop + 注解拦截
该拦截方案思路是由于springboot所有业务相关接口都是通过 @RestController
或 @Controller
注入的,(引入特殊框架产生的接口除外)所以通过对该注解进行切面,即可拦截所有请求,对于白名单的设置,可以设置一个注解,判断调用的接口是否添加该注解即可进行过滤,针对不需要登录的接口,只要加上注解即可。
1.添加免登录注解
/**
* 免登录接口注解,使用方法:在需要免登录的接口添加该注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LoginNoValidator {
}
复制代码
添加aop相关依赖
<!--aop相关的依赖引入-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
复制代码
添加切点、通过环绕增强进行拦截
@Component
@Order(1) // 设置优先级最高
@Slf4j
@Aspect
public class LoginAspect {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Value(value="${cjhx.tokenExpTime}")
private String tokenExpTime;
@Autowired
private LoginService loginService;
/**
* 切点,所有RestController类注解的方法
* 拦截类或者是方法上标注注解的方法
*/
@Pointcut(value = "@within(org.springframework.web.bind.annotation.RestController)")
public void pointCut() {}
@Around("pointCut()")
public Object before(ProceedingJoinPoint joinpoint) throws Throwable {
// 获取方法方法上的LoginValidator注解
MethodSignature methodSignature = (MethodSignature)joinpoint.getSignature();
Method method = methodSignature.getMethod();
LoginNoValidator loginNoValidator = method.getAnnotation(LoginNoValidator.class);
// 如果有,则不校验
if (loginNoValidator != null) {
return joinpoint.proceed(joinpoint.getArgs());
}
// 正常校验 获取request和response
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null || requestAttributes.getResponse() == null) {
// 如果不是从前端过来的,没有request,则直接放行
return joinpoint.proceed(joinpoint.getArgs());
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
// 获取token
String token = request.getHeader(SysConstant.TOKEN_HEADER_NAME);
// FIXME token校验、token续期等
return result;
}
/**
* 返回未登录的错误信息
* @param response ServletResponse
*/
private void returnNoLogin(HttpServletResponse response) {
log.info("用户登录校验失败");
PrintWriter writer=null;
try {
response.setContentType("application/json;charset=UTF-8");
writer=response.getWriter();
BaseResponse baseResponse= BaseResponse.fail(ErrorCode.LOGIN);
writer.write(JsonUtil.obj2String(baseResponse));
} catch (IOException e) {
log.error(e.getMessage(),e);
}finally {
writer.close();
}
}
}
复制代码
对不需要校验的接口添加注解
例如:
@ApiOperation(value = "员工登录接口")
@PostMapping("user/emp/login")
@LoginNoValidator
public BaseResponse employeeLogin(@Valid @RequestBody EmpLoginReq empLoginReq){
LoginUser user=loginService.employLoginAuth(empLoginReq);
return BaseResponse.ok(user);
}
复制代码
三种方式的调用优先级
第一种基于servlet,所以它的优先级最高,在进入springmvc拦截器之前能拦截所有请求,而 Interceptor
是spring MVC 的拦截器,他优先级低于WebFilter 最后,通过 aop 切 @RestController
这种方式相对来说优先级最低,虽然方便,但是在使用的过程中也要考虑项目中是否有其他拦截器和它的冲突
获取登录用户信息(当前登录用户上下文)
由于调用每个接口都是独立的一个线程,所以在此为了方便获取用户信息,我们考虑使用ThreadLocal的方式,利用空间换取时间。将信息保存在线程中,可以随时获取。
关于ThreadLocal
先看关于这个类的API:docs.oracle.com/javase/8/do…
它的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。
ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
这里推荐一遍文章:juejin.cn/post/709775…,介绍的比较详细,更多的不在赘述。
这里直接使用:
改造方法
现建立一个工具类,里面是一个ThreadLocal对象,存储用户信息:
public class LoginUserUtil {
/** 线程池变量 */
private static final ThreadLocal<LoginUser> LOGIN_USER = new ThreadLocal<>();
private LoginUserUtil() {}
public static LoginUser get() {
return LOGIN_USER.get();
}
public static void put(LoginUser user) {
LOGIN_USER.set(user);
}
public static void remove() {
LOGIN_USER.remove();
}
}
复制代码
然后对上面的切面进行改造
@Around("pointCut()")
public Object before(ProceedingJoinPoint joinpoint) throws Throwable {
// 获取方法方法上的LoginValidator注解
MethodSignature methodSignature = (MethodSignature)joinpoint.getSignature();
Method method = methodSignature.getMethod();
LoginNoValidator loginNoValidator = method.getAnnotation(LoginNoValidator.class);
// 如果有,则不校验
if (loginNoValidator != null) {
return joinpoint.proceed(joinpoint.getArgs());
}
// 正常校验 获取request和response
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes == null || requestAttributes.getResponse() == null) {
// 如果不是从前段过来的,没有request,则直接放行
return joinpoint.proceed(joinpoint.getArgs());
}
HttpServletRequest request = requestAttributes.getRequest();
HttpServletResponse response = requestAttributes.getResponse();
// 获取token
String token = request.getHeader(SysConstant.TOKEN_HEADER_NAME);
if (StringUtils.isBlank(token)) {
returnNoLogin(response);
return null;
}
// token 校验
// 从redis中拿token对应user
LoginUser user = loginService.tokenAuth(token);
Object result;
try {
LoginUserUtil.put(user);
// 放行
result=joinpoint.proceed(joinpoint.getArgs());
}finally { // 保证ThreadLocal对象 必须删除,防止内存泄漏
LoginUserUtil.remove();
}
return result;
}
复制代码
这里注意:使用ThreadLocal一定要记得remove!!!防止内存泄漏
关于用户踢下线的改造方案
在原来的设计中 token使用了UUID的方式,在redis中以uuid作为key,用户信息作为value存储在redis中,这种方式有一个问题,当用户在登录后,需要强制踢下线,或者在仅允许用户在一台设备登录的情况就无法实现,这里记录一下我的改造方案,当然有更好的方案,也欢迎大家提出。
方案原理:
- 生成token改为使用使用aes加密算法,将用户id,用户类型、登录方式等需要区分的用户信息再加上时间戳生成一个token(我自己使用了用户id、用户类型和一个租户号三个要素再加时间戳)
- 存入redis时,将上述的用户id、用户类型等信息组成key,存入redis中。注意,再value中除了存储完整的用户信息外,还要存一下当前使用的token,方便后续校验使用。
- 校验时,需要先对token进行解密,获取到用户id等信息,然后在和redis中的key进行校验并拿到完整的用户上下文信息
核心代码:
public LoginUser tokenAuth(String token) {
String key = decryptTokenKey(token);
if(StringUtils.isEmpty(key))
throw new BusinessException("token不合法");
LoginUser user=(LoginUser)redisTemplate.opsForValue().get(key);
if(user==null){
throw new BusinessException(ErrorCode.LOGIN.named(),"登录已失效,请重新登录");
}
if(loginProperties.getSingleLogin() && !StringUtils.equals(token,user.getAccessToken())){
throw new BusinessException(ErrorCode.LOGIN.named()
,"您的账号可能使用其他方式登录了,请重新登录,如果非您本人操作,请及时修改密码");
}
// 续期token
redisTemplate.expire(key,loginProperties.getTokenExpTime(),TimeUnit.MINUTES);
return user;
}
复制代码
@ConfigurationProperties(prefix = "login")
@Component
@Data
public class LoginProperties {
// token过期时间/分钟
private Long tokenExpTime;
// 秘钥key,(加密),生成方法在AesUtil中
private String aesKey;
// 是否开启用户单次登录
private Boolean singleLogin = false;
//向工具类设置加密秘钥
@PostConstruct
private void init(){
AesUtil.setGlobalkey(aesKey);
}
}
网友评论