原文链接:https://blog.csdn.net/weixin_44852935/article/details/107683290
一、Spring Boot 整合Jwt + Shiro 实现认证和权限控制
1.导入依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.2.0</version>
</dependency>
2.在utils包下编写JwtUtil类
JwtUtil类是用来生成token和验校验解码token的。
步骤:
- 设置密钥和token的有效时间
- 生成token
- 校验token
- 获取token的信息
public class JWTUtil {
//token有效时长 1小时
private static final long EXPIRE = 60 * 60 * 1000L;
//token的密钥,自定义算法
private static final String SECRET = "jwt+shiro";
/**
* 生成token
* @param user
* @return
* @throws UnsupportedEncodingException
*/
public static String createToken(User user) throws UnsupportedEncodingException {
//token过期时间
Date date = new Date(System.currentTimeMillis() + EXPIRE);
//使用jwt的api生成token
String token = JWT.create()
.withClaim("username", user.getUsername())//私有声明
.withExpiresAt(date)//过期时间
.withIssuedAt(new Date())//签发时间
.sign(Algorithm.HMAC256(SECRET));//签名
return token;
}
//校验token的有效性,1、token的header和payload是否没改过;2、没有过期
public static boolean verify(String token) {
try {
//解密
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET))
.build();
verifier.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
//无需解密也可以获取token的信息
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 判断过期
* @param token
* @return
*/
public static boolean isExpire(String token) {
DecodedJWT jwt = JWT.decode(token);
return System.currentTimeMillis() > jwt.getExpiresAt().getTime();
}
}
3.封装token,在shiro包下新建JWTToken类
封装token来替换Shiro原生Token,要实现AuthenticationToken接口
shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。
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;
}
}
4.在shiro包下编写JWT的过滤器
这个过滤器是我们的重点,这里我们继承的是Shiro内置的BasicHttpAuthenticationFilter,一个可以内置了可以自动登录方法的的过滤器。也可以继承AuthenticatingFilter。我这里两个都实现了,跑项目的时候只要一个就好了
这个过滤器是要注册到shiro配置里面去的,用来辅助shiro进行过滤处理。所有的请求都会到过滤器来进行处理。
我们需要重写几个方法:
isAccessAllowed
:是否允许访问。如果带有 token,则对 token 进行检查,否则直接通过。如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
isLoginAttempt
:判断用户是否想要登入。检测 header 里面是否包含 Token 字段。
executeLogin
:executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token,然后调用getSubject方法来获取当前用户再调用login方法来实现登录,这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
preHandle
:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
- 继承BasicHttpAuthenticationFilter
public class JWTFilter extends BasicHttpAuthenticationFilter {
//是否允许访问,如果带有 token,则对 token 进行检查,否则直接通过
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
//判断请求的请求头是否带上 "Token"
if (isLoginAttempt(request, response)) {
//如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
//token 错误
responseError(response, e.getMessage());
}
}
//如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
return true;
}
/**
* 判断用户是否想要登入。
* 检测 header 里面是否包含 Token 字段
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
System.out.println("isLoginAttempt");
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("Authorization");
return token != null;
}
/*
* executeLogin实际上就是先调用createToken来获取token,这里我们重写了这个方法,就不会自动去调用createToken来获取token
* 然后调用getSubject方法来获取当前用户再调用login方法来实现登录
* 这也解释了我们为什么要自定义jwtToken,因为我们不再使用Shiro默认的UsernamePasswordToken了。
* */
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("executeLogin");
HttpServletRequest req = (HttpServletRequest) request;
String token = req.getHeader("Authorization");
JWTToken jwt = new JWTToken(token);
//交给自定义的realm对象去登录,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwt);
return true;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
System.out.println("执行JWTFilter 的 preHandle方法~~");
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
res.setHeader("Access-control-Allow-Origin", req.getHeader("Origin"));
res.setHeader("Access-control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
res.setHeader("Access-control-Allow-Headers", req.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
res.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /unauthorized/**
*/
private void responseError(ServletResponse response, String message) {
System.out.println("responseError");
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
//设置编码,否则中文字符在重定向时会变为空字符串
message = URLEncoder.encode(message, "UTF-8");
httpServletResponse.sendRedirect("/unauthorized/" + message);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
}
5.在shiro包下编写自定义得Realm对象
Realm
是shiro进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写3个方法,分别是
supports
:为了让realm支持jwt的凭证校验doGetAuthorizationInfo
:权限校验doGetAuthenticationInfo
:登录认证校验
@Component
public class MyRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
//根据token判断此Authenticator是否使用该realm
//必须重写不然shiro会报错
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如@RequiresRoles,@RequiresPermissions之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
System.out.println("授权~~~~~");
String token = principals.toString();
String username = JWTUtil.getUsername(token);
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("username", username);
User user = userService.getOne(wrapper);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//查询数据库来获取用户的角色
info.addRole(user.getRoles());
//查询数据库来获取用户的权限
info.addStringPermission(user.getPermission());
return info;
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可,在需要用户认证和鉴权的时候才会调用
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
System.out.println("认证~~~~~~~");
JWTToken token = (JWTToken) auth;
String jwtToken = (String) token.getCredentials();
String username= null;
//decode时候出错,可能是token的长度和规定好的不一样了
try {
username= JWTUtil.getUsername(jwtToken);
}catch (Exception e){
throw new AuthenticationException("token非法,不是规范的token,可能被篡改了,或者过期了");
}
if (!JWTUtil.verify(jwtToken)||username==null){
throw new AuthenticationException("token认证失效,token错误或者过期,重新登陆");
}
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("username",username);
User user = userService.getOne(wrapper);
if (user==null){
throw new AuthenticationException("该用户不存在");
}
return new SimpleAuthenticationInfo(jwtToken, jwtToken,getName());
}
}
6.在shiro包下编写ShiroConfig配置类
配置文件的任务主要有:
- 创建
defaultWebSecurityManagerBean
对象 - 创建
ShiroFilterFactoryBean
来进行 过滤拦截,权限和登录 - 关闭session
- 添加注解权限开发
springBoot整合jwt与单纯的shiro实现认证有三个不一样的地方,对应下面
- 因为不适用Session,所以为了防止会调用
getSession()
方法而产生错误,需要关闭session - 一些修改,关闭SHiroDao等
- 注册
JwtFilter
到ShiroFilterFactoryBea
中
@Configuration
public class ShiroConfig {
/**
* 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
*/
@Bean
public ShiroFilterFactoryBean factory(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new LinkedHashMap<>();
//设置我们自定义的JWT过滤器
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
// 设置无权限时跳转的 url;
factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
Map<String, String> filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
// 访问 /unauthorized/** 不通过JWTFilter
// filterRuleMap.put("/login", "anon");
filterRuleMap.put("/unauthorized/**", "anon");
filterRuleMap.put("/user/register", "anon");
filterRuleMap.put("/user/export", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
@Bean(name = "securityManager")
public DefaultWebSecurityManager securityManager(MyRealm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置自定义 realm.
securityManager.setRealm(myRealm);
//关闭shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/* @Bean
public MyRealm getRealm(){
MyRealm myRealm = new MyRealm();
//设置hashed凭证匹配器
HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
//设置md5加密
credentialsMatcher.setHashAlgorithmName("md5");
//设置散列次数
credentialsMatcher.setHashIterations(1024);
myRealm.setCredentialsMatcher(credentialsMatcher);
return myRealm;
}*/
/**
* 下面是 添加注解支持,如果不加的话很有可能注解失效
*/
@Bean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
7.捕获shiro异常
因为是前后端分离项目,所以我们需要有一个规范的反馈机制,而不是返回一些没有意义的错误给前端。我在这里做了全局的异常捕获,这里不多解释了。
大坑:如果不写这个类,当发送请求的请求没有权限或者没有认证之类的将不会显示报错信息,所以一定要写这个类,并且加上注解全局捕获
@ControllerAdvice
public class NoPermissionException {
@ResponseBody
@ExceptionHandler(UnauthorizedException.class)
public Result handleShiroException(Exception ex) {
return Result.error("无权限");
}
@ResponseBody
@ExceptionHandler(AuthorizationException.class)
public Result AuthorizationException(Exception ex) {
return Result.error("权限认证失败");
}
}
8. 编写controller
准备工作:
这里使用了密码加密,操作数据库端使用了MyBatis-Plus,做了个简单的数据库设计。
- 数据库设计
CREATE TABLE `user` (
`id` int(200) NOT NULL AUTO_INCREMENT,
`username` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户名',
`password` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '密码',
`salt` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '盐值',
`roles` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色',
`permission` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '权限',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 21 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
- 密码加密工具类
返回的加密密码是用原密码加盐加1024次哈希散列,这里盐在注册的时候用了随机生成UUID做盐
/**
* 密码加密工具类
**/
public class MD5Utils {
/**
* 密码加密
* @return
*/
public static String md5Encryption(String source,String salt){
int hashIterations = 1024;//加密次数
Md5Hash md5Hash = new Md5Hash(source, salt, hashIterations);
return md5Hash.toHex();
}
}
- 导入数据库相关依赖和MyBatis-Plus相关依赖
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.1</version>
</dependency>
- application.properties相关配置
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/shirodemo?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456
logging.level.root=info
logging.level.com.tong.dao=debug
server.port=8081
8.1用户注册,这里只为了看得更清楚,方便学习,把Service 相关接口和实现类写一起了
/**
* 用户注册
*/
@GetMapping("register")
public Result registUser(String username,String password){
boolean flag = false;
User user = new User();
String salt = UUID.randomUUID().toString().substring(0,32);
user.setSalt(salt);
user.setUsername(username);
user.setPassword(MD5Utils.md5Encryption(password,salt));
user.setRoles("admin");
user.setPermission("user:add");
//mybatis-plus操作数据库
int insert = userDao.insert(user);
if (insert > 0) {
flag = true;
}
if (falg) {
return Result.succ("注册成功");
}
return Result.error("注册失败");
}
8.2登陆
@GetMapping("/userLogin")
public Result userLogin(@RequestParam String username, @RequestParam String password) throws UnsupportedEncodingException {
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("username", username);
User user = userService.getOne(wrapper);
Assert.notNull(user,"用户不存在~");
String pwd = MD5Utils.md5Encryption(password, user.getSalt());
if (!pwd.equals(user.getPassword())){
throw new RuntimeException("密码错误~");
}
String token = JWTUtil.createToken(user);
return Result.success("登陆成功",token);
}
8.3其他测试相关
@RequiresAuthentication
@GetMapping("/test")
public Result test(){
return Result.succ("test请求");
}
@RequiresRoles("admin")
@GetMapping("/admin")
public Result admin(){
return Result.succ("admin请求");
}
@RequiresRoles("vip")
@PostMapping("/vip")
public Result vip(){
return Result.succ("vip请求");
}
@RequiresPermissions("user:update")
@PutMapping("/update")
public Result update(){
return Result.succ("update请求");
}
@RequiresPermissions("user:delete")
@DeleteMapping("/delete")
public Result delete(){
return Result.succ("delete请求");
}
@GetMapping("/add")
@RequiresPermissions("user:add")
public Result add(){
return Result.succ("add请求");
}
9.测试
9.1测试注册
在数据库中可以看到刚刚注册的用户
9.2测试登陆
测试成功就会返回一个token
9.3测试权限
上面注册的用户是拥有admin角色,下面我们测试访问vip看是否能访问到
-
这里我们先不带token看看测试结果
-
带上登陆返回的token测试
测试结果是无权限,因为没有这个用户权限
-
测试admin请求
-
测试update请求
-
测试add请求
10.常用注解接口
之前说了,在需要鉴权的接口方法上面加上注解就可以对该接口进行鉴权了,不需要去config里面一一配置。
- @RequiresAuthentication
这个注解的作用就是,要求用户登录了之后才可以访问这个接口。加上了这个注解,服务器会先判断传递过来的请求头是否带有token,如果没有直接拒绝访问,如果带有会进行认证部分,认证通过了就可以访问,不通过拒绝访问。
注意注意(敲黑板了!!!):这个注解还没有涉及到鉴权,所以是不会走授权部分的。
- @RequiresRoles
这个注解是用来鉴别用户的角色的,拥有这个角色的用户才可以访问这个接口。
- @RequiresPermissions
这个注解是用来鉴别用户的权限的,拥有这个权限的用户才可以访问这个接口。
当我们写的接口拥有以上的注解时,如果请求没有带有 token 或者带了 token 但权限认证不通过,则会报 UnauthenticatedException 异常.