SpringBoot整合Shiro + JWT实现用户认证
登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Shiro + JWT实现登录及用户认证
Shiro相较于Spring Security而言是一款轻量级的安全框架,使用它我们可以不在数据库中设计权限相关的表,如果我们只需要处理匿名可访问接口和登录后可访问接口,那么使用Shiro将会很方便。在之前的博客里,我介绍了Spring Security的使用,以及JWT的由来等,有需要的可以传送:
【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
文章目录
- SpringBoot整合Shiro + JWT实现用户认证
- 登录及访问接口流程
- SpringBoot整合Shiro + JWT
- Shiro整合redis
- pom.xml添加相应依赖
- 先写一个JWT工具类:JwtUtils
- 登录接口
- shiro进行用户认证的核心类:AuthorizingRealm
- JwtFilter
- 整合所有组件,进行Shiro全局配置:ShiroConfig
- 对需要登录才能请求的接口使用@RequiresAuthentication注解
- 前端可以做什么:路由权限拦截
登录及访问接口流程
在此使用最基本的用户名密码登录来举例,首次登录流程如下:
用户访问接口流程如下:
SpringBoot整合Shiro + JWT
Shiro整合redis
Shiro进行认证和授权是默认基于session实现的,这里有2个问题,一是我们这里要使用JWT进行用户认证,用户不能通过session方式登录,二是Shiro将权限数据和会话信息保存在session中不能在集群或负载均衡中使用(因为不同服务器中的session不共享)。因此,考虑使用redis来存储shiro的缓存和会话信息,这里我们使用一个开源的shiro-redis-spring-boot-starter的jar包:shiro-redis
pom.xml添加相应依赖
除了一些基本的依赖,我们还需要添加jwt和shiro-redis依赖
<dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis-spring-boot-starter</artifactId> <version>3.2.1</version> </dependency> <!-- jwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
先写一个JWT工具类:JwtUtils
我们需要写一个JWT工具类JwtUtils,该工具类需要有3个功能:生成JWT、解析JWT、判断JWT是否过期。直接上代码:
@Data @Component @ConfigurationProperties(prefix = "xiaolinbao.jwt") public class JwtUtils { private long expire; private String secret; private String header; // 生成JWT public String generateToken(long userId) { Date nowDate = new Date(); Date expireDate = new Date(nowDate.getTime() + 1000 * expire); return Jwts.builder() .setHeaderParam("typ", "JWT") .setSubject(userId+"") .setIssuedAt(nowDate) .setExpiration(expireDate) // 7天过期 .signWith(SignatureAlgorithm.HS512, secret) .compact(); } // 解析JWT public Claims getClaimsByToken(String jwt) { try { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(jwt) .getBody(); } catch (Exception e) { return null; } } // 判断JWT是否过期 public boolean isTokenExpired(Claims claims) { return claims.getExpiration().before(new Date()); } }
登录接口
登录接口是可匿名访问接口,若用户名密码正确,则生成jwt并写入http response的header中返回:
@PostMapping("/login") public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) { MUser user = userService.getOne(new QueryWrapper<MUser>().eq("username", loginDto.getUsername())); Assert.notNull(user, "用户不存在"); if (!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) { return Result.fail("密码不正确"); } String jwt = jwtUtils.generateToken(user.getId()); response.setHeader("Authorization", jwt); response.setHeader("Access-control-Expose-Headers", "Authorization"); return Result.succ(MapUtil.builder().put("id", user.getId()) .put("username", user.getUsername()) .put("avatar", user.getAvatar()) .put("email", user.getEmail()) .map()); }
shiro进行用户认证的核心类:AuthorizingRealm
我们需自定义继承于AuthorizingRealm的类,称为AccountRealm,该类需重写3个方法,分别是:
- supports:为了让AuthorizingRealm支持jwt的凭证校验
- doGetAuthorizationInfo:定义权限校验的过程,由于我们只有匿名接口和登录接口两种情况,不包含权限管理,因此该方法在此可以不进行具体实现
- doGetAuthenticationInfo:进行用户认证校验,即在访问非匿名接口时,判断请求是否携带了正确的jwt
shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们需要自定义一个JwtToken,来完成shiro的supports方法。JwtToken需要实现AuthenticationToken接口,AuthenticationToken接口中定义了两个方法getPrincipal
和getCredentials
,本意分别是表示获取用户信息,以及获取只被Subject 知道的秘密值。由于我们无法直接从jwt中获得用户的全部信息,只能从jwt中解析出用户名或用户ID,再从数据库中查询才能得到用户实体,所以这两个方法我们都返回jwt token。这样可能有违本意,但不会影响程序运行
public class JwtToken implements AuthenticationToken { private String token; public JwtToken(String jwt) { this.token = jwt; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
doGetAuthenticationInfo方法中定义用户认证校验过程,该方法处理的是非匿名接口的访问,会去判断请求是否具有正确的JWT。该方法的返回值为AuthenticationInfo接口,表示用户信息的载体,我们可以返回SimpleAuthenticationInfo类,该类间接实现了了AuthenticationInfo接口:
该类的第一个属性表示用户信息,我们可以定义一个用户信息封装类AccountProfile,到时候将其作为SimpleAuthenticationInfo构造函数中的第一个参数:
@Data public class AccountProfile implements Serializable { private Long id; private String username; private String avatar; private String email; }
AccountRealm类的完整代码如下:
@Component public class AccountRealm extends AuthorizingRealm { @Autowired JwtUtils jwtUtils; @Autowired MUserService userService; @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { return null; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { JwtToken jwtToken = (JwtToken) authenticationToken; String userId = jwtUtils.getClaimsByToken((String) jwtToken.getPrincipal()).getSubject(); MUser user = userService.getById(Long.valueOf(userId)); if (user == null) { throw new UnknownAccountException("账户不存在"); } if (user.getStatus() == -1) { throw new LockedAccountException("账户已被锁定"); } AccountProfile profile = new AccountProfile(); BeanUtil.copyProperties(user, profile); return new SimpleAuthenticationInfo(profile, jwtToken.getCredentials(), getName()); } }
JwtFilter
我们需自定义过滤器JwtFilter,该类继承于Shiro内置的AuthenticatingFilter,AuthenticatingFilter内置了登录方法executeLogin
我们需要重写以下方法:
- createToken:我们需要从http请求的header中拿到jwt,并将其封装成我们自定义的JwtToken
- onAccessDenied:进行拦截校验过程,当请求不含JWT时,我们直接通过放行(因为匿名也能访问一些接口,即使匿名去访问需要权限的接口,也会被权限注解拦截,因此是安全的);当带有JWT的时候,首先我们校验jwt的有效性,正确我们就直接执行executeLogin方法实现自动登录
- onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
- preHandle:拦截器的前置拦截,在这里由于我写的是前后端分离项目,项目中除了需要跨域全局配置之外,我们在拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
JwtFilter的完整代码如下:
@Component public class JwtFilter extends AuthenticatingFilter { @Autowired JwtUtils jwtUtils; @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { HttpServletRequest request = (HttpServletRequest) servletRequest; String jwt = request.getHeader("Authorization"); if (StringUtils.isEmpty(jwt)) { return true; } else { // 校验jwt Claims claims = jwtUtils.getClaimsByToken(jwt); if (claims == null || jwtUtils.isTokenExpired(claims)) { throw new ExpiredCredentialsException("token已失效,请重新登录"); } // 执行登录 return executeLogin(servletRequest, servletResponse); } } @Override protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { HttpServletRequest request = (HttpServletRequest) servletRequest; String jwt = request.getHeader("Authorization"); if (StringUtils.isEmpty(jwt)) { return null; } return new JwtToken(jwt); } @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpServletResponse = (HttpServletResponse) response; Throwable throwable = e.getCause() == null ? e : e.getCause(); Result result = Result.fail(throwable.getMessage()); String json = JSONUtil.toJsonStr(result); try { httpServletResponse.getWriter().println(json); } catch (IOException ioException) { } return false; } @Override protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception { HttpServletRequest httpServletRequest = WebUtils.toHttp(request); HttpServletResponse httpServletResponse = WebUtils.toHttp(response); httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin")); httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE"); httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers")); // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态 if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) { httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value()); return false; } return super.preHandle(request, response); } }
整合所有组件,进行Shiro全局配置:ShiroConfig
我们主要要在ShiroConfig中做3件事:
- 引入RedisSessionDAO和RedisCacheManager,这是为了解决shiro的权限数据和会话信息能保存到redis中,实现会话共享。
- 为了整合redis,重写了SessionManager和DefaultWebSecurityManager,同时在DefaultWebSecurityManager中关闭shiro自带的session,这样用户就不能通过session方式登录shiro,只能采用jwt凭证登录。
- 在ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有并且正确就进行Shiro的自动登录,没有JWT就跳过放行。跳过放行之后,有Controller中的shiro注解进行再次拦截,比如@RequiresAuthentication,这样来控制需要权限的访问,因此是安全的。
ShiroConfig的完整代码如下:
@Configuration public class ShiroConfig { @Autowired JwtFilter jwtFilter; @Bean public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); return sessionManager; } @Bean public DefaultWebSecurityManager securityManager(AccountRealm accountRealm, SessionManager sessionManager, RedisCacheManager redisCacheManager) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm); securityManager.setSessionManager(sessionManager); securityManager.setCacheManager(redisCacheManager); /* * 关闭shiro自带的session,详情见文档 */ DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); return securityManager; } @Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition(); Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/**", "jwt"); // 主要通过注解方式校验权限 chainDefinition.addPathDefinitions(filterMap); return chainDefinition; } @Bean("shiroFilterFactoryBean") public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, ShiroFilterChainDefinition shiroFilterChainDefinition) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); Map<String, Filter> filters = new HashMap<>(); filters.put("jwt", jwtFilter); shiroFilter.setFilters(filters); Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap(); shiroFilter.setFilterChainDefinitionMap(filterMap); return shiroFilter; } // 开启注解代理(默认好像已经开启,可以不要) @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){ AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor(); authorizationAttributeSourceAdvisor.setSecurityManager(securityManager); return authorizationAttributeSourceAdvisor; } @Bean public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); return creator; } }
对需要登录才能请求的接口使用@RequiresAuthentication注解
举个logout的例子:
@RequiresAuthentication @GetMapping("/logout") public Result logout() { SecurityUtils.getSubject().logout(); return Result.succ(null); }
前端可以做什么:路由权限拦截
我们在后端用Shiro定义了哪些接口是只有登录才能请求的,我们还可以在前端加上路由权限拦截,控制一下哪些页面是需要登录之后才能跳转的,如果未登录就访问对应页面就直接重定向到登录页面让用户登录,这样更加安全
以Vue为例,我们需要在src目录下定义一个js文件,可称为permission.js:
import router from "./router" // 路由判断登录 根据路由配置文件的参数 router.beforeEach((to, from, next) => { if (to.matched.some(record => record.meta.requireAuth)) { // 判断该路由是否需要登录权限 const token = localStorage.getItem("token") console.log("------------" + token) if (token) { // 判断当前的token是否存在(登录时存入的token) if (to.path === '/login') { } else { next() } } else { next({ path: '/login' }) } } else { next() } })
然后我们在定义页面路由的时候定义meta信息,指定requireAuth: true,则该路由需要登录才能访问。
最后在main.js中import我们的permission.js: