关于本文
其实用户登录拦截的这块不想这么早写,加个登录后面好多东西就要考虑登录状态了,我其实想把这个系列写成非必要关系,解耦性比较强
的系列。但是,写完redis,总是感觉登录是对它最简单的实践,那就加上吧,反正后面很多文章也会用到,但大多文章我仍会不考虑登录状态。
这里只是讲明白登录机制,如何实现。实际使用中会考虑很多别的,例如用户权限,登录机制限制等等~这里就先不做过多的叙述。
这里只讲技术和实现,不讲任何业务场景哈,牵扯到场景的问题就会复杂N倍,而且通用性往往不尽人意~
本文依赖于redis和mybatis plus,这些都是最基础的模块,所以都放在最前面写了,大家可以线过一下相关的文章。
【笑小枫的SpringBoot系列】【八】SpringBoot集成Redis
【笑小枫的SpringBoot系列】【三】SpringBoot集成Mybatis Plus
本文是基于jwt+redis来实现。接下来我们一起看看吧
什么是JWT
什么是JWT,JWT(全称:Json Web Token)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。 该信息可以被验证和信任,因为它是数字签名的。
上面说法比较文绉绉,简单点说就是一种认证机制,让后台知道该请求是来自于受信的客户端
JWT的优点
- json格式的通用性,所以JWT可以跨语言支持,比如Java、JavaScript、PHP、Node等等。
- 可以利用Payload存储一些非敏感的信息。
- 便于传输,JWT结构简单,字节占用小。
- 不需要在服务端保存会话信息,易于应用的扩展。
JWT的缺点
- 安全性没法保证,所以jwt里不能存储敏感数据。因为jwt的payload并没有加密,只是用Base64编码而已。
- 无法中途废弃。因为一旦签发了一个jwt,在到期之前始终都是有效的,如果用户信息发生更新了,只能等旧的jwt过期后重新签发新的jwt。
- 续签问题。当签发的jwt保存在客户端,客户端一直在操作页面,按道理应该一直为客户端续长有效时间,否则当jwt有效期到了就会导致用户需要重新登录。
补偿JWT的缺点
- 针对JWT的缺点,我们在使用的过程中,只储存常用的无敏感数据,比如用户ID,用户角色等。
- 中途废弃和续签问题,通过和redis配合使用,将token返回时,同步保存redis,通过控制token在redis的有效期来进行控制。
- 还可以通过统计redis有效数据,对在线用户进行统计或强制下线等操作。
用户登录流程
以用户登录功能为例,程序流程如下:
用户登录
token认证访问
注:系统中采用JWT对用户登录授权验证。
基于Token的身份验证流程
使用基于Token的身份验证,在服务端不需要存储用户的登录记录。大概的流程是这样的:
1、客户端使用用户名或密码请求登录;
2、服务端收到请求,去验证用户名与密码;
3、验证成功后,服务端会使用JWT签发一个Token,保存到Redis中,同时再把这个Token发送给客户端;
4、客户端收到Token以后可以把它存储起来,比如放在Cookie里或者Local Storage里;
5、客户端每次向服务端请求资源的时候需要在请求Header里面带着服务端签发的Token;
6、服务端收到请求,然后去验证客户端请求里面带着的Token,如果验证成功,就向客户端返回请求的数据。验证失败,返回失败原因。
功能实现
自动生成的User.java、UserMapper.java、UserMapper.xml…就不贴代码了,没有业务代码,且占的篇幅过大。在SpringBoot集成Mybatis Plus文章中创建过就可以忽略了哈~
代码生成见SpringBoot集成Mybatis Plus一文。
涉及到的表sql
在SpringBoot集成Mybatis Plus文章中创建过的就可以忽略了
CREATE TABLE `usc_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `account` varchar(30) DEFAULT NULL COMMENT '用户账号', `user_name` varchar(30) DEFAULT NULL COMMENT '用户姓名', `nick_name` varchar(30) DEFAULT NULL COMMENT '用户昵称', `user_type` varchar(2) DEFAULT '00' COMMENT '用户类型(00系统用户,01小程序用户)', `email` varchar(50) DEFAULT '' COMMENT '用户邮箱', `phone` varchar(11) DEFAULT '' COMMENT '手机号码', `sex` char(1) DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)', `avatar` varchar(100) DEFAULT '' COMMENT '头像地址', `salt` varchar(32) DEFAULT NULL COMMENT '用户加密盐值', `password` varchar(100) DEFAULT '' COMMENT '密码', `status` char(1) DEFAULT '0' COMMENT '帐号状态(0正常 1停用)', `create_id` bigint(20) DEFAULT NULL COMMENT '创建人id', `create_name` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_id` bigint(20) DEFAULT NULL COMMENT '更新人id', `update_name` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `delete_flag` tinyint(1) DEFAULT '0' COMMENT '删除标志', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='用户中心-用户信息表';
引入依赖
首先我们在pom文件中引入依赖
<!-- 引入JWT相关 --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.18.3</version> </dependency>
通用类
先把我们的通用类创建一下,一些常量,我们统一放在一个配置中,方便日后维护。
- 在config.bean创建通用类
GlobalConfig.java
package com.maple.demo.config.bean; /** * @author 笑小枫 * @date 2022/7/20 */ public class GlobalConfig { private GlobalConfig() { } /** * 用户储存在redis中的过期时间 */ public static final long EXPIRE_TIME = 60 * 60 * 12L; /** * 生成token的私钥 */ public static final String SECRET = "maple123"; /** * 前端传递token的header名称 */ public static final String TOKEN_NAME = "Authorization"; /** * 用户登录token保存在redis的key值 * * @param account 用户登录帐号 * @return token保存在redis的key */ public static String getRedisUserKey(String account) { return "MAPLE_ADMIN:" + account; } }
- config.bean创建
TokenBean.java
保存的jwt的信息
package com.maple.demo.config.bean; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; /** * @author 笑小枫 * @date 2022/7/20 */ @Data @Builder @RequiredArgsConstructor @AllArgsConstructor public class TokenBean { /** * 用户ID */ private Long userId; /** * 用户账号 */ private String account; /** * 用户类型 */ private String userType; }
JWT工具类
在我们的util包下创建JwtUtil.java
工具类👇
package com.maple.demo.util; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.DecodedJWT; import com.maple.demo.config.bean.ErrorCode; import com.maple.demo.config.bean.GlobalConfig; import com.maple.demo.config.bean.TokenBean; import com.maple.demo.config.exception.MapleCheckException; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.util.Objects; /** * Jwt常用操作 * * @author 笑小枫 * @date 2022/7/20 */ public class JwtUtil { private static final String ACCOUNT = "account"; private static final String USER_ID = "userId"; private static final String USER_TYPE = "userType"; /** * 校验token是否正确 * * @param token 密钥 * @return 是否正确 */ public static boolean verify(String token, String account) { try { Algorithm algorithm = Algorithm.HMAC256(GlobalConfig.SECRET); JWTVerifier verifier = JWT.require(algorithm).withClaim(ACCOUNT, account).build(); verifier.verify(token); return true; } catch (Exception exception) { return false; } } /** * 获得token中的信息无需secret解密也能获得 * * @return token中包含的用户登录帐号 */ public static String getAccount() { try { DecodedJWT jwt = getJwt(); if (jwt == null) { return null; } return jwt.getClaim(ACCOUNT).asString(); } catch (JWTDecodeException e) { return null; } } /** * 获得token中的信息无需secret解密也能获得 * * @return token中包含的用户登录帐号 */ public static String getAccount(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim(ACCOUNT).asString(); } catch (JWTDecodeException e) { return null; } } public static Long getUserId() { try { DecodedJWT jwt = getJwt(); if (jwt == null) { return null; } return jwt.getClaim(USER_ID).asLong(); } catch (JWTDecodeException e) { return null; } } /** * 获得token中的信息无需secret解密也能获得 * * @return token中包含的用户ID */ public static Long getUserId(String token) { try { DecodedJWT jwt = JWT.decode(token); return jwt.getClaim(USER_ID).asLong(); } catch (JWTDecodeException e) { return null; } } public static TokenBean getTokenMsg() { TokenBean tokenBean = new TokenBean(); try { DecodedJWT jwt = getJwt(); if (jwt == null) { return tokenBean; } tokenBean.setUserId(jwt.getClaim(USER_ID).asLong()); tokenBean.setAccount(jwt.getClaim(ACCOUNT).asString()); return tokenBean; } catch (JWTDecodeException e) { return tokenBean; } } private static DecodedJWT getJwt() { ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (Objects.isNull(servletRequestAttributes)) { throw new MapleCheckException(ErrorCode.PARAM_ERROR); } HttpServletRequest request = servletRequestAttributes.getRequest(); String authorization = request.getHeader(GlobalConfig.TOKEN_NAME); if (authorization == null) { return null; } return JWT.decode(authorization); } /** * 校验token是否有效 * * @param token token信息 * @return 返回结果 */ public static boolean verifyToken(String token) { try { JWTVerifier verifier = JWT.require(Algorithm.HMAC256(GlobalConfig.SECRET)).build(); DecodedJWT jwt = verifier.verify(token); jwt.getClaims(); return true; } catch (Exception e) { e.printStackTrace(); return false; } } /** * 创建token * * @param tokenBean token保存的信息 * @return token */ public static String createToken(TokenBean tokenBean) { Algorithm algorithm = Algorithm.HMAC256(GlobalConfig.SECRET); return JWT.create() .withClaim(USER_ID, tokenBean.getUserId()) .withClaim(ACCOUNT, tokenBean.getAccount()) .withClaim(USER_TYPE, tokenBean.getUserType()) .sign(algorithm); } }
使用Filter进行登录拦截
牵扯到了三个异常code,我们可以在ErrorCode.java
里面补充一下,如果没有引入自定义异常,可以手动throw new RuntimeException("笑小枫的异常信息")
替换一下就行了。
NO_TOKEN("1001", "用户未登录"), TOKEN_EXPIRE("1002", "登陆超时,请重新登录"), TOKEN_EXCHANGE("1003", "账号在其他地方登录,账号被踢出"), USER_LOGIN_ERROR("2001", "用户名或密码错误"), USER_STATUS_ERROR("2002", "用户已被停用,请联系管理员"),
首先在MapleDemoApplication.java
启动项上添加注解@ServletComponentScan
。
SpringBootApplication
上使用@ServletComponentScan
注解后Servlet可以直接通过@WebServlet注解自动注册
Filter可以直接通过@WebFilter注解自动注册
Listener可以直接通过@WebListener 注解自动注册
创建一个包filter
,然后在包内创建JwtFilter
,代码如下:👇
package com.maple.demo.filter; import com.alibaba.fastjson.JSON; import com.maple.demo.config.bean.ErrorCode; import com.maple.demo.config.bean.GlobalConfig; import com.maple.demo.util.JwtUtil; import com.maple.demo.util.RedisUtil; import com.maple.demo.util.ResultJson; import lombok.AllArgsConstructor; import org.springframework.beans.factory.BeanFactory; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.util.StringUtils; import org.springframework.web.context.support.WebApplicationContextUtils; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.regex.Pattern; /** * 判断用户登录token * * @author 笑小枫 * @date 2022/07/20 */ @WebFilter(filterName = "jwtFilter", urlPatterns = {"/*"}) @AllArgsConstructor @Order(1) public class JwtFilter implements Filter { private final List<String> excludedUrlList; @Override public void init(FilterConfig filterConfig) { excludedUrlList.addAll(Arrays.asList( "/sso/login", "/sso/logout", "/example/*", "/webjars/**", "/swagger/**", "/v2/api-docs", "/doc.html", "/swagger-ui.html", "/swagger-resources/**", "/swagger-resources" )); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String url = ((HttpServletRequest) request).getRequestURI(); boolean isMatch = false; for (String excludedUrl : excludedUrlList) { if (Pattern.matches(excludedUrl.replace("*", ".*"), url)) { isMatch = true; break; } } if (isMatch) { chain.doFilter(request, response); } else { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; //处理跨域问题,跨域的请求首先会发一个options类型的请求 if (httpServletRequest.getMethod().equals(HttpMethod.OPTIONS.name())) { chain.doFilter(request, response); } BeanFactory factory = WebApplicationContextUtils.getRequiredWebApplicationContext(request.getServletContext()); RedisUtil redisService = (RedisUtil) factory.getBean("redisUtil"); String account; String authorization = httpServletRequest.getHeader(GlobalConfig.TOKEN_NAME); // 判断token是否存在,不存在代表未登录 if (StringUtils.isEmpty(authorization)) { writeRsp(httpServletResponse, ErrorCode.NO_TOKEN); return; } else { account = JwtUtil.getAccount(authorization); String token = (String) redisService.get(GlobalConfig.getRedisUserKey(account)); // 判断token是否存在,不存在代表登陆超时 if (StringUtils.isEmpty(token)) { writeRsp(httpServletResponse, ErrorCode.TOKEN_EXPIRE); return; } else { // 判断token是否相等,不相等代表在其他地方登录 if (!token.equalsIgnoreCase(authorization)) { writeRsp(httpServletResponse, ErrorCode.TOKEN_EXCHANGE); return; } } } // 保存redis,每次调用成功都刷新过期时间 redisService.set(GlobalConfig.getRedisUserKey(account), authorization, GlobalConfig.EXPIRE_TIME); chain.doFilter(httpServletRequest, httpServletResponse); } } @Override public void destroy() { Filter.super.destroy(); } private void writeRsp(HttpServletResponse response, ErrorCode errorCode) { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.setHeader("content-type", "application/json;charset=UTF-8"); try { response.getWriter().println(JSON.toJSON(new ResultJson(errorCode.getCode(), errorCode.getMsg()))); } catch (IOException e) { e.printStackTrace(); } } }