登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Shiro + JWT实现登录及用户认证
Shiro相较于Spring Security而言是一款轻量级的安全框架,使用它我们可以不在数据库中设计权限相关的表,如果我们只需要处理匿名可访问接口和登录后可访问接口,那么使用Shiro将会很方便。在之前的博客里,我介绍了Spring Security的使用,以及JWT的由来等,有需要的可以传送:
【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
在此使用最基本的用户名密码登录来举例,首次登录流程如下:
用户访问接口流程如下:
Shiro进行认证和授权是默认基于session实现的,这里有2个问题,一是我们这里要使用JWT进行用户认证,用户不能通过session方式登录,二是Shiro将权限数据和会话信息保存在session中不能在集群或负载均衡中使用(因为不同服务器中的session不共享)。因此,考虑使用redis来存储shiro的缓存和会话信息,这里我们使用一个开源的shiro-redis-spring-boot-starter的jar包:shiro-redis
除了一些基本的依赖,我们还需要添加jwt和shiro-redis依赖
<dependency>
<groupId>org.crazycakegroupId>
<artifactId>shiro-redis-spring-boot-starterartifactId>
<version>3.2.1version>
dependency>
<dependency>
<groupId>io.jsonwebtokengroupId>
<artifactId>jjwtartifactId>
<version>0.9.1version>
dependency>
我们需要写一个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());
}
我们需自定义继承于AuthorizingRealm的类,称为AccountRealm,该类需重写3个方法,分别是:
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,该类继承于Shiro内置的AuthenticatingFilter,AuthenticatingFilter内置了登录方法executeLogin
我们需要重写以下方法:
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);
}
}
我们主要要在ShiroConfig中做3件事:
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;
}
}
举个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: