springboot3+jdk17整合jwt+shiro+redis实现登录认证
注意,jdk17的规范是Jakarta EE,虽然最新版本shiro适配springboot3,但是部分包要单独适配
- 首先讲一下整体流程
- 用户首次登录时,会发送一个包含用户名和密码的请求到服务器。这个请求通常是一个POST请求,发送到一个特定的登录URL,例如 /user/login。
- 服务器接收到请求后,会验证用户名和密码。如果验证成功,服务器会生成一个JWT,并将其发送回用户。这个JWT包含了用户的一些信息,例如用户ID,以及一个签名。签名是用服务器的私钥生成的,可以用来验证JWT的真实性和完整性。
- 用户收到JWT后,会将其存储在本地,例如在浏览器的localStorage中。然后,每次发送请求到服务器时,都会在请求的Header中携带这个JWT,通常是在 Authorization 字段中。
- 服务器接收到请求后,会首先检查请求的Header中是否包含JWT。如果没有包含,那么服务器就会拒绝这个请求,因为它无法验证请求的来源。如果包含了JWT,那么服务器就会验证这个JWT的签名。如果签名验证失败,那么服务器也会拒绝这个请求,因为这意味着JWT可能被篡改。如果签名验证成功,那么服务器就会从JWT中提取出用户信息,然后处理这个请求。
- 在处理请求时,服务器可能还需要进行授权检查,例如检查用户是否有权限访问某个资源。这个检查通常是通过查询数据库来完成的。
- 如果用户长时间没有活动,那么服务器会认为用户已经退出登录,此时的JWT就会过期。用户再次发送请求时,服务器就会发现JWT已经过期,然后拒绝这个请求。用户需要重新登录,以获取新的JWT。
- 在每次用户请求时,服务器会检查他们的JWT令牌是否即将过期。如果令牌即将过期,并且用户在Redis中被标记为活跃,那么服务器会自动为他们续签令牌。这个过程在JwtFilter类的onAccessDenied方法中实现。
- 如果用户在过去2小时内有任何活动,那么他们会被标记为活跃用户。这是通过在每次用户请求时调用markUserActive方法来实现的。这个方法会在Redis中设置一个键,键的格式是 “active_users:” 加上当前的日期和时间,并将这个键对应的位(由用户ID指定)设置为 true。这个键的过期时间为2小时。
- 如果用户在过去2小时内没有任何活动,那么他们的活跃状态就会被移除,即Redis中对应的键会过期并被删除。如果这时用户发送的请求中的JWT令牌即将过期,那么服务器不会为他们续签令牌,而是要求他们重新登录。
-
整个认证流程登录和登录之后每一次的认证是不一样的,
-
登录后由工具类jwtUtil生成一个token,返回给前端用于之后每次请求
引入依赖
-
pom.xml
,注意jwt相关包版本不能太低<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <!-- shiro --> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <classifier>jakarta</classifier> <version>1.12.0</version> <!-- 排除仍使用了javax.servlet的依赖 --> <exclusions> <exclusion> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> </exclusion> <exclusion> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> </exclusion> </exclusions> </dependency> <!-- 引入适配jakarta的依赖包 --> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <classifier>jakarta</classifier> <version>1.12.0</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> <classifier>jakarta</classifier> <version>1.12.0</version> <exclusions> <exclusion> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> </exclusion> </exclusions> </dependency> <!-- jwt创建、解析、验证 JWT 的功能,并且支持对 JWT 进行签名和加密 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- jedis 是 Redis 官方推荐的 Java 客户端,提供了比较全面的 Redis 命令的支持。--> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.14.2</version> </dependency>
ShiroConfig
-
在
config
目录下创建一个Shiro配置类package com.example.englishhub.config; /** * @Author: hahaha * @Date: 2024/4/11 16:29 */ import com.example.englishhub.security.JwtFilter; import com.example.englishhub.security.JwtRealm; import jakarta.servlet.Filter; import org.apache.shiro.mgt.DefaultSessionStorageEvaluator; import org.apache.shiro.mgt.DefaultSubjectDAO; import org.apache.shiro.spring.LifecycleBeanPostProcessor; import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor; import org.apache.shiro.spring.web.ShiroFilterFactoryBean; import org.apache.shiro.web.mgt.DefaultWebSecurityManager; import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import java.util.HashMap; import java.util.LinkedHashMap; /** * shiro的三个重要配置 * 1、Realm * 2、DefaultWebSecurityManager * 3、ShiroFilterFactoryBean */ /** 1.用户请求,不携带token,就在JwtFilter处进行错误处理返回,让它去登陆 2.用户请求,携带token,就到JwtFilter中获取jwt,使用JwtRealm进行认证,若token过期则返回401状态码 3.在JwtRealm中进行认证判断这个token是否有效, */ @Configuration public class ShiroConfig { @Bean("securityManager") public DefaultWebSecurityManager securityManager(@Qualifier("jwtRealm") JwtRealm jwtRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); // 设置自定义Realm securityManager.setRealm(jwtRealm); // 关闭shiroDao功能,关闭session DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); // 不需要将ShiroSession中的东西存到任何地方包括Http Session中) defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); // securityManager.setSubjectFactory(subjectFactory()); return securityManager; } @Bean("shiroFilterFactoryBean") public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) { /** * 注册jwt过滤器,除/login,/register外都先经过jwtFilter * * 先经过过滤器,如果检测到请求头存在 token,则用 token 去 login,接着走 Realm 去验证 */ ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); HashMap<String, Filter> filterMap = new HashMap<>(); filterMap.put("jwt", new JwtFilter()); shiroFilter.setFilters(filterMap); LinkedHashMap<String, String> map = new LinkedHashMap<>(); map.put("/user/login", "anon"); map.put("/user/register", "anon"); // 所有请求通过我们自己的JWT Filter map.put("/**", "jwt"); shiroFilter.setFilterChainDefinitionMap(map); return shiroFilter; } /** * 解决@RequiresAuthentication注解不生效的配置 */ @Bean("lifecycleBeanPostProcessor") public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } @Bean @DependsOn({"lifecycleBeanPostProcessor"}) public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); advisorAutoProxyCreator.setProxyTargetClass(true); return advisorAutoProxyCreator; } /** * 为Spring-Bean开启对Shiro注解的支持 */ @Bean("authorizationAttributeSourceAdvisor") public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) { AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } }
-
在启动后,首先加载
securityManager
,关闭了原有的session(因为使用了jwtToken),将自定义的Realm注入(在其中完成对jwtToken的验证) -
之后添加我们自定义的jwt过滤器,起到拦截请求的作用(就相当于拦截器),排除特殊接口如登录注册以及接口文档
JwtToken
package com.example.englishhub.security;
/**
* @Author: hahaha
* @Date: 2024/4/11 17:26
*/
import org.apache.shiro.authc.AuthenticationToken;
/**
* 继承AuthenticationToken,跟JwtRealmh中的doGetAuthenticationInfo的参数类型保持一致
*/
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token){
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtFilter
-
我在项目目录下新建了一个
security
存放安全认证相关的类package com.example.englishhub.security; import com.example.englishhub.exception.JwtValidationException; import com.example.englishhub.utils.Result; import com.example.englishhub.utils.ResultType; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.web.filter.AccessControlFilter; import org.apache.shiro.web.util.WebUtils; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import javax.security.sasl.AuthenticationException; import java.io.IOException; import java.nio.charset.StandardCharsets; /** * @Description: JwtFilter * shiro的过滤器,用于拦截请求, * @Author: hahaha * @Date: 2024/4/11 17:26 */ @Slf4j @Component public class JwtFilter extends AccessControlFilter { /** * isAccessAllowed()判断是否携带了有效的JwtToken * onAccessDenied()是没有携带JwtToken的时候进行账号密码登录 */ @Override protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception { /** * 1. 返回true,shiro就直接允许访问url * 2. 返回false,shiro才会根据onAccessDenied的方法的返回值决定是否允许访问url * 这里先让它始终返回false来使用onAccessDenied()方法 * 如果带有 token,则对 token 进行检查,否则直接通过 * */ try { onAccessDenied(servletRequest, servletResponse); // return true; } catch (Exception e) { log.error("isAccessAllowed error:", e); responseError(servletResponse, ResultType.UNAUTHORIZED.getCode(), "Authentication failed: " + e.getMessage()); // return false; } return true; } /** * @param servletRequest * @param servletResponse * @throws Exception * @return 返回结果为true表明登录通过 */ @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { /** * 跟前端约定将jwtToken放在请求的Header的token中,token:token */ log.info("onAccessDenied方法被调用"); // HttpServletResponse response = (HttpServletResponse) servletResponse; HttpServletRequest request = (HttpServletRequest) servletRequest; String token = request.getHeader("token"); //如果token为空的话,返回true,交给控制层进行判断;也会达到没有权限的作用 if (token == null) { responseError(servletResponse, ResultType.UNAUTHORIZED.getCode(), "No token provided"); return false; } JwtToken jwtToken = new JwtToken(token); try { //进行登录处理,委托realm进行登录认证,调用JwtRealm进行的认证,doGetAuthenticationInfo getSubject(servletRequest, servletResponse).login(jwtToken); return true; } catch (JwtValidationException e) { // Handle specific custom exceptions responseError(servletResponse, e.getStatusCode(), e.getMessage()); return false; } // catch (AuthenticationException e) { // responseError(servletResponse, ResultType.UNAUTHORIZED.getCode(), "Authentication failed: " + e.getMessage()); // return false; // } } @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { System.out.println("进入拦截器"); HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 设置CORS头部 resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin")); resp.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); resp.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,token"); resp.setHeader("Access-Control-Allow-Credentials", "true"); if ("OPTIONS".equals(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return false; // 阻止后续的过滤器链执行 } return super.preHandle(request, response); } //失败要执行的方法 private void responseError(ServletResponse response, String statusCode, String message) throws IOException { HttpServletResponse resp = WebUtils.toHttp(response); resp.setStatus(HttpStatus.UNAUTHORIZED.value()); resp.setCharacterEncoding("UTF-8"); resp.setContentType("application/json; charset=utf-8"); ObjectMapper mapper = new ObjectMapper(); try (ServletOutputStream out = resp.getOutputStream()) { Result<String> result = new Result<>(); result.setStatusCode(statusCode); result.setMessage(message); String json = mapper.writeValueAsString(result); out.write(json.getBytes(StandardCharsets.UTF_8)); } catch (IOException e) { throw new AuthenticationException("Response writing failed with IOException: " + e.getMessage()); } } }
-
在登录之后的每次请求都会携带
token
,这可以通过前端配置一个请求拦截器实现,在后端从header头中取出该token,通过JwtToken类封装成shrio接受的token,委托JwtRealm
认证,传入JwtToken
JwtRealm
package com.example.englishhub.security;
import com.example.englishhub.exception.JwtValidationException;
import com.example.englishhub.service.UserService;
import com.example.englishhub.utils.JwtUtil;
import com.example.englishhub.utils.ResultType;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @Description: JwtRealm
* shiro的Realm,用于处理JwtToken的认证和授权
* @Author: hahaha
* @Date: 2024/4/11 17:26
*/
@Slf4j
@Component
public class JwtRealm extends AuthorizingRealm {
@Resource
private JwtUtil jwtUtil;
@Autowired
private UserService userService;
/**
* 多重写一个support
* 标识这个Realm是专门用来验证JwtToken
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String jwt = (String) token.getCredentials();
// 获取jwt中关于用户id,解码过程中如果token过期或者被篡改会抛出异常
// String id = null;
log.info("处理jwt认证", jwt);
try {
// Validate the token
String id = jwtUtil.validateToken(jwt);
log.info("jwt认证成功,用户id:", id);
// 标记用户为活跃状态
userService.markUserActive(Integer.parseInt(id));
return new SimpleAuthenticationInfo(jwt, jwt, getName());
}
catch (ExpiredJwtException e) {
throw new JwtValidationException(ResultType.UNAUTHORIZED.getCode(), "Token已过期,请重新登录");
} catch (JwtException e) {
throw new JwtValidationException(ResultType.UNAUTHORIZED.getCode(), "无效的Token");
}
}
/**
* 授权时调用
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return new SimpleAuthorizationInfo();
}
}
-
调用工具类
JwtUtil
,解析token获取其中的用户id,并对token过期等情况进行处理,抛出异常被捕获 -
然后在filter层走失败的方法,返回结果给前端
JwtUtil
package com.example.englishhub.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.context.annotation.Configuration;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
/**
* jwt工具类
*
* @author hahaha
*/
@Configuration
public class JwtUtil {
// 30 秒
private static long EXPIRATION_TIME = 1000 * 30;
// 1 hour
// private static long EXPIRATION_TIME = 3600000 * 1;
// 一天
// private static long EXPIRATION_TIME = 3600000 * 1;
//private static long EXPIRATION_TIME = 10000 * 10;
// private static String SECRET = "MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjY34DFDSSSd";// 秘钥
// 使用Base64编码的密钥
private static String SECRET = Base64.getEncoder().encodeToString("MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjY34DFDSSSd".getBytes());
private static final String USER_ID = "id";
/**
* 生成jwtToken
*
* @param id
* @return
*/
public static String generateToken(String id) {
HashMap<String, Object> map = new HashMap<>();
// you can put any data in the map
map.put(USER_ID, id);
// 1. setClaims(map):将map中的数据存储到Claims中
// 2. setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)):设置过期时间
// 3. signWith(SignatureAlgorithm.HS512, SECRET):设置加密算法和密钥
// 4. compact():生成token
String token = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setClaims(map)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
return token;
}
/**
* 校验jwtToken
*
* @param token
* @return
*/
// 优化验证逻辑
public static String validateToken(String token) {
Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
return claims.get(USER_ID, String.class);
// // 使用自定义异常
// if (StringUtils.isBlank(token)) {
// throw new JwtValidationException(ResultType.UNAUTHORIZED.getCode(), "Token为空");
// }
// try {
// Claims claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
// return claims.get(USER_ID, String.class);
// } catch (ExpiredJwtException e) {
// throw new JwtValidationException(ResultType.AGAIN_LOGIN.getCode(), "Token已过期,请重新登录");
// } catch (JwtException e) {
// throw new JwtValidationException(ResultType.UNAUTHORIZED.getCode(), "无效的Token");
// }
}
public static void main(String[] args) {
String id = "hahaha15";
String token = generateToken(id);
System.out.println(token);
//token = "eyJhbGciOiJIUzUxMiJ9.eyJpZCI6IjY4NzZhYjFmYjk0MmZkNGYyN2Zm";
id = validateToken(token);
System.out.println(id);
// HashMap<String, Object> map = new HashMap<>();
// // you can put any data in the map
// map.put("name", id);
// token = Jwts.builder().setClaims(map).setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
// .signWith(SignatureAlgorithm.