• SpringBoot+Shiro+JWT实现授权


    一、需求

    1、前后端分离项目,前端Vue,后端SpringBoot,需要实现后端鉴权;

    2、当前端发送请求之后,判断是否登录或用户是否具有相应权限;

    3、使用Token实现,不使用Session。

    二、实现思路

    1、前端Vue的axios设置请求拦截器,在请求头中添加token;

    2、后端自定义JWT拦截器,继承BasicHttpAuthenticationFilter,

            如果请求头中含有token,则进行认证鉴权操作;

            如果请求头中不含有token,禁止访问需要登录之后才能访问的资源;

    3、ShiroFilterFactoryBean中自定义拦截url,排除不需要验证的界面(如login、404、401等),其余请求的url,一律拦截至我们自定义的JWTFilter;

    4、因为使用JWT验证身份,不存在Session,若没有使用缓冲池,每次鉴权都需要执行登录操作,重新获取身份信息,鉴权前执行登录操作;

    5、调用自定义Realm中的doGetAuthorizationInfo,从数据库中获取该用户所对应角色或权限,进行鉴权。

    三、Shiro授权原理

    授权流程

    流程如下:

    1. 首先调用 Subject.isPermitted*/hasRole*接口,其会委托给 SecurityManager,而 SecurityManager 接着会委托给 Authorizer;
    2. Authorizer 是真正的授权者,如果我们调用如 isPermitted(“user:view”),其首先会通过 PermissionResolver 把字符串转换成相应的 Permission 实例;
    3. 在进行授权之前,其会调用相应的 Realm 获取 Subject 相应的角色/权限用于匹配传入的角色/权限;
    4. Authorizer 会判断 Realm 的角色/权限是否和传入的匹配,如果有多个 Realm,会委托给 ModularRealmAuthorizer 进行循环判断,如果匹配如 isPermitted*/hasRole* 会返回 true,否则返回 false 表示授权失败。

    四、关键代码

    首先是pom.xml

    1. <dependency>
    2. <groupId>org.apache.shirogroupId>
    3. <artifactId>shiro-spring-boot-starterartifactId>
    4. <version>1.6.0version>
    5. dependency>
    6. <dependency>
    7. <groupId>io.jsonwebtokengroupId>
    8. <artifactId>jjwtartifactId>
    9. <version>0.9.1version>
    10. dependency>

     JWTtoken的加密、解析和check已经在我的其他博客写过

    1、自定义JWTToken,继承自AuthenticationToke

            JWTToken差不多就是Shiro用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,简单的实现下AuthenticationToken接口即可,因为项目使用了MD5加密,数据库存储的密码是加盐之后的密码,此处还要保存一下加盐之后的密码,将getCredentials返回为这个加盐之后的密码,因为授权还需要进行一次登录操作,此处又不能使用明文密码,所有只能将加密后的密码再使用相同的盐再加密,再将“加密之后的密码”按照相同的方法加密之后(此处有点绕嘴),再放入SimpleAuthenticationInfo中,在执行Subject.login()。(有更好的方法可以分享一下)

    1. public class JWTToken implements AuthenticationToken {
    2. private String token;
    3. // 用户密码(数据库里存储的用户加密之后的密码)
    4. private String credentials;
    5. // 有参构造
    6. public JWTToken(String token) {
    7. this.token = token;
    8. }
    9. // 无参构造
    10. public JWTToken() {
    11. }
    12. @Override
    13. public Object getPrincipal() {
    14. return this.token;
    15. }
    16. @Override
    17. public Object getCredentials() {
    18. return this.credentials;
    19. }
    20. public void setCredentials(String credentials) {
    21. this.credentials = credentials;
    22. }
    23. }

    2、自定义JWT拦截器

            我们使用的是 shiro 默认的权限拦截 Filter,而因为JWT的整合,我们需要自定义自己的过滤器 JWTFilter,JWTFilter 继承了 BasicHttpAuthenticationFilter,并部分方法进行了重写。所有的请求都会先经过Filter,所以我们继承官方的BasicHttpAuthenticationFilter,并且重写鉴权的方法。代码执行的流程:

    preHandle -> isAccessAllowed -> isLoginAttempt -> executeLogin

    1. public class JWTFilter extends BasicHttpAuthenticationFilter {
    2. /**
    3. * 最后始终返回true
    4. * @param request
    5. * @param response
    6. * @param mappedValue
    7. * @return
    8. */
    9. @Override
    10. protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    11. System.out.println("JWTFilter启动");
    12. //指定请求不经过该过滤器
    13. if(((HttpServletRequest) request).getRequestURI().endsWith("login")){
    14. return true;
    15. }
    16. // 判断请求的请求头是否带token属性,也就是判断用户是否一定登录
    17. if(isLoginAttempt(request, response)){
    18. // 如果请求头中包含token,则执行executeLogin方法进行登入操作,检查token是否正确
    19. System.out.println("用户已经登录");
    20. try {
    21. return executeLogin(request, response);
    22. }catch (Exception e){
    23. e.printStackTrace();
    24. }
    25. }else {
    26. System.out.println("用户没有登录");
    27. try {
    28. Result result = new Result(Code.TOKEN_INVALID, null, "Token为空!");
    29. this.returnErrorMsg(response, result);
    30. }catch (IOException e){
    31. e.printStackTrace();
    32. }
    33. }
    34. return false;
    35. }
    36. /**
    37. * 判断用户是否想要登录
    38. * 在请求头中检查是否有token就行
    39. * @param request
    40. * @param response
    41. * @return
    42. */
    43. @Override
    44. protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
    45. // System.out.println("判断用户是否想要登入");
    46. HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    47. return null != httpServletRequest.getHeader("token");
    48. }
    49. /**
    50. * 执行登录
    51. * @param request
    52. * @param response
    53. * @return
    54. * @throws Exception
    55. */
    56. @Override
    57. protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    58. System.out.println("执行登录");
    59. HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    60. String token = httpServletRequest.getHeader("token");
    61. try{
    62. getSubject(request, response).login(new JWTToken(token));
    63. return true;
    64. }catch (Exception e){
    65. e.printStackTrace();
    66. Result result = new Result(Code.TOKEN_INVALID, null, "Token失效!");
    67. this.returnErrorMsg(response, result);
    68. return false;
    69. }
    70. }
    71. /**
    72. * 返回给前端自定义错误信息
    73. * @param response
    74. * @param result
    75. * @throws IOException
    76. */
    77. private void returnErrorMsg(ServletResponse response,Result result) throws IOException {
    78. //响应token为空
    79. response.setContentType("application/json;charset=UTF-8");
    80. response.setCharacterEncoding("UTF-8");
    81. //清空第一次流响应的内容
    82. response.resetBuffer();
    83. //转成json格式
    84. ObjectMapper object = new ObjectMapper();
    85. String asString = object.writeValueAsString(result);
    86. response.getWriter().println(asString);
    87. }
    88. /**
    89. * 因为前后端分离,需对跨域提供支持
    90. * @param request
    91. * @param response
    92. * @return
    93. * @throws Exception
    94. */
    95. @Override
    96. protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
    97. // System.out.println("跨域支持");
    98. HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    99. HttpServletResponse httpServletResponse = (HttpServletResponse) response;
    100. httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
    101. httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
    102. httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
    103. // 跨域时会首先发送一个 option请求,这里我们给 option请求直接返回正常状态
    104. if(httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())){
    105. httpServletResponse.setStatus(HttpStatus.OK.value());
    106. return false;
    107. }
    108. return super.preHandle(request, response);
    109. }
    110. }

    上面代码的大致分析:

    ①、拦截所有的请求,先通过Interceptor解决跨域请求问题;

    ②、isAccessAllowed方法启动,启动之后,调用isLoginAttempt方法;

    ③、isLoginAttempt是判断用户是想要执行登录操作还是要授权登录,判断的依据就是请求头里面是否含有token;

    ④、如果请求头里面含有token,就说明用户已经登录,那就再执行一次登录,用于鉴权;如果没有登录,那就返回前端Result;

    3、Shiro全局配置类中增加自定义jwtFilter过滤器,用来拦截并处理携带JWT token的请求

    1. /**
    2. * Shiro配置类
    3. */
    4. @Configuration
    5. public class ShiroConfig {
    6. /*
    7. * 倒序配置
    8. * 1、先自定义过滤器 MyRealm
    9. * 2、创建第二个DefaultWebSecurityManager,将MyRealm注入
    10. * 3、装配第三个ShiroFilterFactoryBean,将DefaultWebSecurityManager注入,并注入认证及授权规则
    11. * */
    12. //3、装配ShiroFilterFactoryBean,并将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
    13. @Bean
    14. public ShiroFilterFactoryBean factoryBean(DefaultWebSecurityManager manager){
    15. ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
    16. factoryBean.setSecurityManager(manager);//将 DefaultWebSecurityManager 注入到 ShiroFilterFactoryBean 中
    17. // 自定义拦截url
    18. Map filterMap = new LinkedHashMap<>();
    19. filterMap.put("jwt", new JWTFilter());
    20. factoryBean.setFilters(filterMap);
    21. //添加默认过滤器
    22. //表示指定登录页面
    23. factoryBean.setLoginUrl("/login");
    24. //未授权页面
    25. //factoryBean.setUnauthorizedUrl("/unauthorized");
    26. //拦截器, 配置不会被拦截的链接 顺序判断
    27. Map filterChainDefinitionMap = new LinkedHashMap<>();
    28. //所有匿名用户均可访问到Controller层的该方法下
    29. filterChainDefinitionMap.put("/login", "anon");
    30. filterChainDefinitionMap.put("/checkToken", "anon");
    31. //user表示配置记住我或认证通过可以访问的地址
    32. //filterChainDefinitionMap.put("/remember", "user");
    33. //filterChainDefinitionMap.put("/logout", "logout");
    34. //authc:所有url都必须认证通过才可以访问; anon:所有url都都可以匿名访问
    35. //filterChainDefinitionMap.put("/**", "authc");
    36. //所有url都必须认证通过jwt过滤器才可以访问
    37. filterChainDefinitionMap.put("/**", "jwt");
    38. factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
    39. //注入认证及授权规则
    40. return factoryBean;
    41. }
    42. //2、创建DefaultWebSecurityManager ,并且将 MyRealm 注入到 DefaultWebSecurityManager bean 中
    43. @Bean
    44. public DefaultWebSecurityManager manager(MyRealm myRealm){
    45. DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
    46. manager.setRealm(myRealm);//将自定义的 MyRealm 注入到 DefaultWebSecurityManager bean 中
    47. /*
    48. * 关闭shiro自带的session
    49. * 用了jwt的访问认证,所以要把默认session支持关掉
    50. * 即不保存用户登录状态,保证每次请求都重新认证
    51. */
    52. DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
    53. DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
    54. defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
    55. subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
    56. manager.setSubjectDAO(subjectDAO);
    57. return manager;
    58. }
    59. //1、自定义过滤器Realm
    60. @Bean
    61. public MyRealm myRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher){
    62. MyRealm myRealm = new MyRealm();
    63. // 密码匹配器
    64. myRealm.setCredentialsMatcher(matcher);
    65. return myRealm;
    66. }
    67. /**
    68. * 密码匹配器
    69. * @return HashedCredentialsMatcher
    70. */
    71. @Bean("hashedCredentialsMatcher")
    72. public HashedCredentialsMatcher hashedCredentialsMatcher(){
    73. HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
    74. // 设置哈希算法名称
    75. matcher.setHashAlgorithmName("MD5");
    76. // 设置哈希迭代次数
    77. matcher.setHashIterations(1024);
    78. // 设置存储凭证(true:十六进制编码,false:base64)
    79. matcher.setStoredCredentialsHexEncoded(true);
    80. return matcher;
    81. }
    82. /**
    83. * SpringShiroFilter首先注册到spring容器
    84. * 然后被包装成FilterRegistrationBean
    85. * 最后通过FilterRegistrationBean注册到servlet容器
    86. * @return
    87. */
    88. @Bean
    89. public FilterRegistrationBean delegatingFilterProxy() {
    90. FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
    91. DelegatingFilterProxy proxy = new DelegatingFilterProxy();
    92. proxy.setTargetFilterLifecycle(true);
    93. proxy.setTargetBeanName("factoryBean");
    94. filterRegistrationBean.setFilter(proxy);
    95. return filterRegistrationBean;
    96. }
    97. /**
    98. * 注解访问授权动态拦截,
    99. * 不然不会执行Realm中的doGetAuthenticationInfo
    100. * @param manager
    101. * @return
    102. */
    103. @Bean
    104. public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager manager){
    105. AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
    106. authorizationAttributeSourceAdvisor.setSecurityManager(manager);
    107. return authorizationAttributeSourceAdvisor;
    108. }
    109. }

    注意点1、关闭Shiro自带的session;

            因为用了jwt的访问认证,所以要把默认session支持关掉。即不保存用户登录状态,保证每次请求都重新认证。

    1. DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
    2. DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
    3. defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
    4. subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
    5. defaultWebSecurityManager.setSubjectDAO(subjectDAO);

    4、配置Realm

    1. /**
    2. * Shiro自定义Realm
    3. */
    4. public class MyRealm extends AuthorizingRealm {
    5. @Autowired
    6. private UserService userService;
    7. /**
    8. * 授权
    9. * @param principalCollection
    10. * @return
    11. */
    12. @Override
    13. protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
    14. System.out.println("获取权限信息");
    15. //1.获取身份信息
    16. User pricipal = (User) principalCollection.getPrimaryPrincipal();
    17. //2.根据身份获取、角色权限等信息
    18. ArrayList roles = new ArrayList<>();
    19. roles = ...自己Service获取Role的方法...;
    20. // System.out.println("用户所具有角色:"+roles);
    21. ArrayList permissions = new ArrayList<>();
    22. permissions = ...自己Service获取Permissions的方法...;
    23. System.out.println("用户所具有权限:"+permissions);
    24. //3.将角色、权限添加到授权信息里面
    25. SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    26. info.addRoles(roles);
    27. info.addStringPermissions(permissions);
    28. return info;
    29. }
    30. /**
    31. * 认证
    32. * @param authenticationToken
    33. * @return
    34. * @throws AuthenticationException
    35. *
    36. * 客户端传来的 username 和 password 会自动封装到 token,先根据 username 进行查询,
    37. * 如果返回 null,则表示用户名错误,直接 return null 即可,Shiro 会自动抛出 UnknownAccountException 异常。
    38. * 如果返回不为 null,则表示用户名正确,再验证密码,直接返回 SimpleAuthenticationInfo 对象即可,
    39. * 如果密码验证成功,Shiro 认证通过,否则返回 IncorrectCredentialsException 异常。
    40. */
    41. @Override
    42. protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    43. //System.out.println("启动认证");
    44. String userId;
    45. // 如果这个token是来自JWTToken,说明此次登录目的是授权验证的登录
    46. if(authenticationToken instanceof JWTToken){
    47. JWTToken token = (JWTToken) authenticationToken;
    48. userId = ...自己解析token获取ID的方法...;
    49. User user = userService.getByUsername(token.getUsername());
    50. token.setCredentials(user.getPassword());
    51. if(user != null && user.getStatus().equals(1)){
    52. // 用数据库中已经加密的密码,再用数据库中对应的盐值加密一次
    53. String pwd2 = SaltUtil.encryption(user.getPassword(), user.getSalt());
    54. // 参数列表(实体信息,密码,盐值,realm名称)
    55. return new SimpleAuthenticationInfo(user,pwd2, ByteSource.Util.bytes(user.getSalt()),getName());
    56. }else{
    57. // 此操作是用户真正执行登录操作
    58. UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
    59. //获取存放到数据库中的实体类
    60. User user = userService.getByUsername(token.getUsername());
    61. //System.out.println(user);
    62. if(user != null && user.getStatus().equals(1)){
    63. // 参数列表(实体信息,密码,盐值,realm名称)
    64. return new SimpleAuthenticationInfo(user,user.getPassword(), ByteSource.Util.bytes(user.getSalt()),getName());
    65. }
    66. }
    67. return null;
    68. }
    69. }

    4、再需要鉴权的Controller上面,添加注释就可以啦

    1. //需要user角色
    2. @RequiresRoles("user")
    3. //必须同时属于user和admin角色
    4. @RequiresRoles({"user","admin"})
    1. //用户具有index:menu权限才可访问
    2. @RequiresPermissions("index:menu")

    五、总结

            1、目前没有使用缓存,每次鉴权都要去数据库查Role、查Permissions;

            2、鉴权时段的登录操作,因为token中不能封装明文密码,又不能移除加密器,所以要对数据库中已经加密的密码,在此基础上再把这个密码进行一次加密,出现了不必要的资源浪费;

            3、虽然实现功能,但是实现过程复杂;

            最后,感谢“桃子屁屁”的大力支持。

  • 相关阅读:
    将 Vue、React、Angular、HTML 等一键打包成 macOS 和 Windows 平台客户端应用
    flaks框架学习:在 URL 中添加变量
    C++ enum与enum class对比
    linux等保整改
    【限流与Sentinel超详细分析】
    Python爬虫-IP隐藏技术与代理爬取
    腾讯mini项目-【指标监控服务重构】2023-08-27
    原生 JS 实现 VS Code 自动切换输入法状态!这次没有AHK
    【zookeeper】ZooKeeper的特点及应用场景
    Base64编码
  • 原文地址:https://blog.csdn.net/Hao_ge_666/article/details/126678602