Springboot+Shiro+Jwt实现权限管理
简要的说明下我们为什么要用JWT,因为我们要实现完全的前后端分离,所以不可能使用session,cookie的方式进行鉴权,所以JWT就被派上了用场,你可以通过一个加密密钥来进行前后端的鉴权,实现无状态鉴权。
之前我一直使用spring security,相比较而言security的功能更强大但是很笨重,shiro功能简单更轻量级。反正各有利弊,应该根据公司现有的技术体系(尽量和公司其他系统框架一致以免浪费很多口舌)。
认证原理
- 用户登陆之后,使用密码对账号进行签名生成并返回token并设置过期时间;
- 将token保存到本地,并且每次发送请求时都在header上携带token。
- shiro过滤器拦截到请求并获取header中的token,并提交到自定义realm的doGetAuthenticationInfo方法。
- 通过jwt解码获取token中的用户名,从数据库中查询到密码之后根据密码生成jwt效验器并对token进行验证。
设计用户权限表
这里我就是简单的用户、角色、权限的表,可以根据自己的需求来即可。当然也可以没有表,用户、角色、权限去会员中心或统一登录中心去获取,只需要把我后面代码中查询本地数据库的改为调用接口即可。
- 用户表
CREATE TABLE `sys_user` (
`id` varchar(255) COLLATE utf8_bin NOT NULL,
`create_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime(6) DEFAULT NULL,
`del_flag` int(11) DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`update_time` datetime(6) DEFAULT NULL,
`avatar` varchar(1000) COLLATE utf8_bin DEFAULT NULL,
`dept_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`description` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`email` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`last_login_time` datetime(6) DEFAULT NULL,
`mobile` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`nick_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`password` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`sex` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`status` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`username` varchar(255) COLLATE utf8_bin NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_pcxnofptxqnyi4wqa3v5lbu21` (`username`),
KEY `idx_deptId` (`dept_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
- 角色表
CREATE TABLE `sys_role` (
`id` varchar(255) COLLATE utf8_bin NOT NULL,
`create_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime(6) DEFAULT NULL,
`del_flag` int(11) DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`update_time` datetime(6) DEFAULT NULL,
`remark` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`role_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
- 权限表(我这里对应的就是菜单,按你自己的需求来。)
CREATE TABLE `sys_menu` (
`id` varchar(255) COLLATE utf8_bin NOT NULL,
`create_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime(6) DEFAULT NULL,
`del_flag` int(11) DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`update_time` datetime(6) DEFAULT NULL,
`component` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`icon` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`menu_name` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`order_num` double DEFAULT NULL,
`parent_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`path` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`perms` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`type` varchar(255) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin ROW_FORMAT=COMPACT;
- 用户VS角色表
CREATE TABLE `sys_user_role` (
`id` varchar(255) COLLATE utf8_bin NOT NULL,
`create_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime(6) DEFAULT NULL,
`del_flag` int(11) DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`update_time` datetime(6) DEFAULT NULL,
`role_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`user_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_userId_roleId` (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
- 角色VS权限表
CREATE TABLE `sys_role_menu` (
`id` varchar(255) COLLATE utf8_bin NOT NULL,
`create_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`create_time` datetime(6) DEFAULT NULL,
`del_flag` int(11) DEFAULT NULL,
`update_by` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`update_time` datetime(6) DEFAULT NULL,
`menu_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
`role_id` varchar(255) COLLATE utf8_bin DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_roleId_menuId` (`role_id`,`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
引入相关pom依赖
基于spring-boot-starter-web,如何创建自行百度。
<!-- shiro-spring -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.1</version>
</dependency>
配置JWT
- 自定义JwtToken,首先我们需要自定义一个对象用来封装token。
JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,所以不需要RememberMe这类功能,我们简单的实现下AuthenticationToken接口即可。因为token自己已经包含了用户名等信息,所以这里我就弄了一个字段。如果你喜欢钻研,可以看看官方的UsernamePasswordToken是如何实现的。
package cn.pconline.config.auth;
import lombok.Data;
import org.apache.shiro.authc.AuthenticationToken;
/**
* @Description JwtToken 传输类
* @Author jie.zhao
* @Date 2019/8/7 10:45
*/
@Data
public class JwtToken implements AuthenticationToken {
private static final long serialVersionUID = 1282057025599826155L;
private String token;
private String exipreAt;
public JwtToken(String token) {
this.token = token;
}
public JwtToken(String token, String exipreAt) {
this.token = token;
this.exipreAt = exipreAt;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
- JwtUtil工具类用来进行签名和效验Token。
我们写一个简单的JWT加密,校验工具,并且使用用户自己的密码充当加密密钥,这样保证了token 即使被他人截获也无法破解。并且我们在token中附带了username信息,并且设置密钥30分钟就会过期。
package cn.pconline.config.auth;
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.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
/**
* @Description Jwt工具类
* @Author jie.zhao
* @Date 2019/8/7 10:59
*/
public class JwtUtil {
/**
* 过期时间30分钟,这里需要根据具体的需求来
*/
private static final long EXPIRE_TIME = 30 * 60 * 1000;
/**
* 校验token是否正确
*
* @param token 密钥
* @param username 用户名
* @param secret 用户的密码
* @return 正确: true;不正确:false
*/
public static boolean verify(String token, String username, String secret) {
// 根据密码生成JWT校验器
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
// 校验TOKEN
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (IllegalArgumentException e) {
e.printStackTrace();
return false;
} catch (JWTVerificationException e) {
e.printStackTrace();
return false;
}
}
/**
* 获取用户名
*
* @param token token中包含了用户名
* @return
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
e.printStackTrace();
return null;
}
}
/**
* 生成签名
*
* @param username 用户名
* @param secret 密码
* @return 加密的TOKEN
*/
public static String sign(String username, String secret) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带用户信息
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
}
}
配置Shiro
- 自定义Realm
realm的用于处理用户是否合法的这一块,需要我们自己实现。
package cn.pconline.config.auth;
import cn.pconline.modules.sys.entity.SysUser;
import cn.pconline.modules.sys.service.SysUserService;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
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 java.util.Set;
/**
* @Description 自定义实现 ShiroRealm,包含认证和授权两大模块
* @Author jie.zhao
* @Date 2019/8/13 11:19
*/
public class MyRealm extends AuthorizingRealm {
@Autowired
private SysUserService userService;
/**
* 必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 授权模块,获取用户角色和权限
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String username = JwtUtil.getUsername(principals.toString());
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
// 获取用户角色集
Set<String> roleSet = userService.getUserRoles(username);
simpleAuthorizationInfo.setRoles(roleSet);
// 获取用户权限集
Set<String> permissionSet = userService.getUserPermissions(username);
simpleAuthorizationInfo.setStringPermissions(permissionSet);
return simpleAuthorizationInfo;
}
/**
* 用户认证
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
* @param authenticationToken 身份认证 token
* @return AuthenticationInfo 身份认证信息
* @throws AuthenticationException 认证相关异常
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 这里的 token是从 JwtFilter 的 executeLogin 方法传递过来的,已经经过了解密
String token = (String) authenticationToken.getCredentials();
String username = JwtUtil.getUsername(token);
if (StringUtils.isBlank(username)) {
throw new AuthenticationException("token校验不通过");
}
// 如果要实现登出逻辑需要将用户和token存储起来(redis、memcache等)这里校验token是否有效
// 通过用户名查询用户信息,也可改为接口验证用户名是否存在(即通过登录中心验证的)
SysUser user = userService.getUser(username);
if (user == null) {
throw new AuthenticationException("用户名或密码错误");
}
/*
* 注意这里的校验
* token
* username 用户名
* secret 用户的密码
*
* 这里要注意secret这个字段,如果本地系统没有用户存储用户密码(即通过登录中心验证的)
* 可以把这个值写成一个固定值,当然这样有一定的风险,或者根据一定的规则生成假的密码来验证。
*
*/
if (!JwtUtil.verify(token, username, user.getPassword())) {
throw new AuthenticationException("token校验不通过");
}
return new SimpleAuthenticationInfo(token, token, "my_realm");
}
}
- 配置ShiroFilter拦截器
所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法。
代码的执行流程preHandle->isAccessAllowed->isLoginAttempt->executeLogin
package cn.pconline.config.auth;
import cn.pconline.config.authentication.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* @Description Jwt过滤器
* @Author jie.zhao
* @Date 2019/8/7 10:47
*/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
private static final String TOKEN = "Authentication";
/**
* 判断用户是否想要登入。
* 检测header里面是否包含Authorization字段即可
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader(TOKEN);
return authorization != null;
}
/**
*
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader(TOKEN);
JwtToken token = new JwtToken(authorization);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问
* 例如我们提供一个地址 GET /article
* 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
* 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request, response)) {
try {
executeLogin(request, response);
} catch (Exception e) {
response401(request, response);
}
}
return true;
}
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) 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"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /401
*/
private void response401(ServletRequest req, ServletResponse resp) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.sendRedirect("/401");
} catch (IOException e) {
log.error(e.getMessage());
}
}
}
- ShiroConfig
package cn.pconline.config.auth;
import cn.pconline.config.authentication.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @Description Shiro 配置类
* @Author jie.zhao
* @Date 2019/8/7 11:02
*/
@Configuration
public class ShiroConfig {
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 配置 SecurityManager,并注入 shiroRealm
securityManager.setRealm(myRealm());
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
@Bean
public MyRealm myRealm() {
// 配置 Realm
return new MyRealm();
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
LinkedHashMap<String, Filter> filters = new LinkedHashMap<>();
filters.put("jwt", new JwtFilter());
factoryBean.setFilters(filters);
factoryBean.setSecurityManager(securityManager);
factoryBean.setUnauthorizedUrl("/401");
/*
* 自定义url规则
* http://shiro.apache.org/web.html#urls-
*/
Map<String, String> filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
// 访问401和404页面不通过我们的Filter
filterRuleMap.put("/401", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 下面的代码是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
简单的登录控制器
对于登出逻辑,其实是不好实现的因为JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
这里我是通过将用户+token存储在redis中,登录时存储起来,在再MyRealm实现token有效性的校验。该逻辑未实现请自己根据自己的业务实现。
以下是伪代码,可根据自己的业务自己实现。
/login
登入
/article
所有人都可以访问,但是用户与游客看到的内容不同
/require_auth
登入的用户才可以进行访问
/require_role
admin的角色用户才可以登入
/require_permission
拥有view和edit权限的用户才可以访问
@RestController
public class WebController {
private static final Logger LOGGER = LogManager.getLogger(WebController.class);
private UserService userService;
@Autowired
public void setService(UserService userService) {
this.userService = userService;
}
@PostMapping("/login")
public ResponseBean login(@RequestParam("username") String username,
@RequestParam("password") String password) {
// 通过用户名查询用户信息,也可改为接口验证用户名是否存在(即通过登录中心验证的)
UserBean userBean = userService.getUser(username);
if (userBean.getPassword().equals(password)) {
return new ResponseBean(200, "Login success", JWTUtil.sign(username, password));
} else {
throw new UnauthorizedException();
}
}
@GetMapping("/article")
public ResponseBean article() {
Subject subject = SecurityUtils.getSubject();
if (subject.isAuthenticated()) {
return new ResponseBean(200, "You are already logged in", null);
} else {
return new ResponseBean(200, "You are guest", null);
}
}
@GetMapping("/require_auth")
@RequiresAuthentication
public ResponseBean requireAuth() {
return new ResponseBean(200, "You are authenticated", null);
}
@GetMapping("/require_role")
@RequiresRoles("admin")
public ResponseBean requireRole() {
return new ResponseBean(200, "You are visiting require_role", null);
}
@GetMapping("/require_permission")
@RequiresPermissions(logical = Logical.AND, value = {"view", "edit"})
public ResponseBean requirePermission() {
return new ResponseBean(200, "You are visiting permission require edit,view", null);
}
@RequestMapping(path = "/401")
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseBean unauthorized() {
return new ResponseBean(401, "Unauthorized", null);
}
}
参考文档: