封面画师:adsuger 封面ID:74171912
1. SpringSecurity
在Web开发中,安全一直都是一个非常重要的方面,如果开发的应用出现安全漏洞,极大概率会造成隐私泄露。虽然它属于应用的非功能性需求,但我们应该在应用设计初期就将其考虑进来,如果不在设计之初就考虑清楚,架构一旦确定再进行更改会对架构产生较大的改动。虽然使用过滤器和拦截器也可以达到安全处理,但市面上也有集成的安全框架。
在市面上有两种比较出名的安全框架,一种是SpringSecurity,一种是Shiro,它们主要用来做两件事:认证、授权。
权限分类:功能权限、访问权限、菜单权限,我们可以使用大量的原生代码编写拦截器或过滤器来实现这些权限。
我们先对SpringSecurity进行简单的介绍。
先来一手官网:SpringSecurity官网 😝
1.1 环境搭建
创建项目时,勾选web与thymeleaf,向项目中添加web与thymeleaf的启动器 。
导入静态资源(目录结构如下):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 index.html |views |level1 1.html 2.html 3.html |level2 1.html 2.html 3.html |level3 1.html 2.html 3.html login.html
为了便于测试,我们可以关闭模板引擎的缓存:
1 spring.thymeleaf.cache=false
编写Controller,实现基本页面跳转,也是相当于测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Controller public class RouterController { @RequestMapping({"/","/index"}) public String index () { return "index" ; } @RequestMapping("/toLogin") public String toLogin () { return "views/login" ; } @RequestMapping("/level1/{id}") public String level1 (@PathVariable("id") int id) { return "views/level1/" +id; } @RequestMapping("/level2/{id}") public String level2 (@PathVariable("id") int id) { return "views/level2/" +id; } @RequestMapping("/level3/{id}") public String level3 (@PathVariable("id") int id) { return "views/level3/" +id; } }
编写好代码后,我们可以运行项目、测试环境!
1.2 初识SpringSecurity
PS:回忆并比较我们学习过的Spring AOP:横切与配置类。
Spring Security 是针对Spring项目的安全框架,也是Spring Boot底层安全模块默认的技术选型,它可以实现强大的Web安全控制,对于安全控制,我们仅需要引入spring-boot-starter-security 模块,进行少量的配置,即可实现强大的安全管理!
记住我们常用的几个类:
WebSecurityConfigurerAdapter:自定义Security策略
AuthenticationManagerBuilder:自定义认证策略
@EnableWebSecurity:开启WebSecurity模式
Spring Security的两个主要目标是 “认证” 和 “授权”(访问控制)。
“认证”(Authentication):
身份验证是关于验证您的凭据,如用户名/用户ID和密码,以验证您的身份。
身份验证通常通过用户名和密码完成,有时与身份验证因素结合使用。
“授权” (Authorization):
授权发生在系统成功验证您的身份后,最终会授予您访问资源(如信息,文件,数据库,资金,位置,几乎任何内容)的完全权限。
这个概念是通用的,而不是只在Spring Security 中存在。
1.3 认证和授权
导入SpringSecurity的启动器:
1 2 3 4 5 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency >
根据官方文档,编写 SpringSecurity 配置类,配置类固定的结构:
1 2 3 4 5 6 7 8 9 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { super .configure(http); } }
在配置类中设计权限规则:
1 2 3 4 5 6 7 8 9 10 @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/" ).permitAll() .antMatchers("/level1/**" ).hasAnyRole("vip1" ) .antMatchers("/level2/**" ).hasAnyRole("vip2" ) .antMatchers("/level3/**" ).hasAnyRole("vip3" ); }
然后,我们可以进行简单的测试。启动程序,进入首页,点击超链接,我们发现除了首页,其他界面都无法进入。报 403 状态,Access Denied。
因为我们没有进行登录,所以当我们进行需要登录的操作时,应该将页面跳转至登录界面,而不是报错。开启登录界面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/" ).permitAll() .antMatchers("/level1/**" ).hasAnyRole("vip1" ) .antMatchers("/level2/**" ).hasAnyRole("vip2" ) .antMatchers("/level3/**" ).hasAnyRole("vip3" ); http.formLogin(); }
然后我们可以进行测试:没有登录但进行相关操作时,跳转至登录界面。
但是我们要怎么为用户设置权限呢?我们可以定义认证规则,重写configure()
方法。SpringSecurity提供了两种方式,一种是在内存中定义,一种是从数据库取得。我们在此演示在内存中定义。
在配置类中定义认证规则:
1 2 3 4 5 6 7 8 9 10 11 12 13 @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder ()) .withUser("yang" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip2" , "vip3" ) .and() .withUser("root" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip1" , "vip2" , "vip3" ) .and() .withUser("guest" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip1" ); }
最后,进行测试!发现登陆成功,每个角色拥有自己的访问权限,且只能进行自己权限下的操作。😉
1.4 注销及权限控制
注销
开启注销功能:
1 2 3 4 5 6 7 @Override protected void configure (HttpSecurity http) throws Exception { http.logout(); }
添加注销超链接:
1 2 3 <a class ="item" th:href ="@{/logout}" > <i class ="address card icon" > </i > 注销 </a >
然后进行测试:登陆成功后点击注销,跳转至登录页面。
但是,如果我们想要注销后跳转至首页,应该怎么做呢?
1 2 http.logout().logoutSuccessUrl("/" );
然后,我们再进行 测试,发现符合我们的预期。
权限控制
现在导航条上有两个按钮(或者说超链接),但是这显然不符合实际情况。我们需要在用户没有登录的时候,导航栏上只显示登录按钮;用户登录成功后,显示用户名、用户角色和注销按钮,同时页面中只显示用户拥有相信权限的内容。
因为我们使用的Thymeleaf模板引擎,因此我们需要结合Thymeleaf中的一些功能。
导入相关依赖:
1 2 3 4 5 6 <dependency > <groupId > org.thymeleaf.extras</groupId > <artifactId > thymeleaf-extras-springsecurity4</artifactId > <version > 3.0.4.RELEASE</version > </dependency >
修改前端界面:
首先导入命名空间:
1 2 <html lang ="en" xmlns:th ="http://www.thymeleaf.org" xmlns:sec ="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4" >
修改导航栏,添加认证判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <div class ="right menu" > <div sec:authorize ="!isAuthenticated()" > <a class ="item" th:href ="@{/toLogin}" > <i class ="address card icon" > </i > 登录 </a > </div > <div sec:authorize ="isAuthenticated()" > <a class ="item" > 用户名:<span sec:authentication ="name" > </span > </a > </div > <div sec:authorize ="isAuthenticated()" > <a class ="item" th:href ="@{/logout}" > <i class ="sign-out icon" > </i > 注销 </a > </div > </div >
PS:在这里我们用到了sec:authorize="isAuthenticated()"
,用来验证是否登录,进而显示不同的界面。
然后我们重启项目,登录系统,查看是否修改成功。
如果我们登录后进行注销产生了 404 错误,因为Security默认防止 CSRF(跨站请求伪造)。Security认为会产生安全问题,因此产生 404 错误,我们可以将请求改为post表单提交,或者在Spring Security中关闭 CSRF功能以消除 404 错误。
我们可以在配置类中增加 http.csrf().disable();
以关闭 Security的防止CSRF功能。
1 2 3 4 5 6 7 8 9 10 11 12 @Override protected void configure (HttpSecurity http) throws Exception { http.csrf().disable(); http.logout ().logoutSuccessUrl("/" ); }
我们还没有写完,因为还没有实现根据用户角色实现界面显示对应的内容。继续修改界面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <div > <br > <div class ="ui three column stackable grid" > <div class ="column" sec:authorize ="hasRole('vip1')" > <div class ="ui raised segment" > <div class ="ui" > <div class ="content" > <h5 class ="content" > Level 1</h5 > <hr > <div > <a th:href ="@{/level1/1}" > <i class ="bullhorn icon" > </i > Level-1-1</a > </div > <div > <a th:href ="@{/level1/2}" > <i class ="bullhorn icon" > </i > Level-1-2</a > </div > <div > <a th:href ="@{/level1/3}" > <i class ="bullhorn icon" > </i > Level-1-3</a > </div > </div > </div > </div > </div > <div class ="column" sec:authorize ="hasRole('vip2')" > <div class ="ui raised segment" > <div class ="ui" > <div class ="content" > <h5 class ="content" > Level 2</h5 > <hr > <div > <a th:href ="@{/level2/1}" > <i class ="bullhorn icon" > </i > Level-2-1</a > </div > <div > <a th:href ="@{/level2/2}" > <i class ="bullhorn icon" > </i > Level-2-2</a > </div > <div > <a th:href ="@{/level2/3}" > <i class ="bullhorn icon" > </i > Level-2-3</a > </div > </div > </div > </div > </div > <div class ="column" sec:authorize ="hasRole('vip3')" > <div class ="ui raised segment" > <div class ="ui" > <div class ="content" > <h5 class ="content" > Level 3</h5 > <hr > <div > <a th:href ="@{/level3/1}" > <i class ="bullhorn icon" > </i > Level-3-1</a > </div > <div > <a th:href ="@{/level3/2}" > <i class ="bullhorn icon" > </i > Level-3-2</a > </div > <div > <a th:href ="@{/level3/3}" > <i class ="bullhorn icon" > </i > Level-3-3</a > </div > </div > </div > </div > </div > </div > </div >
然后,我们就可以进行测试验证功能是否实现了。
1.5 记住我及首页定制
记住我
所谓“记住我”,就是用户登录后关掉浏览器,再次打开浏览器时不用再次登录。
直接去配置类中开启记住我:
1 2 3 4 5 6 7 @Override protected void configure (HttpSecurity http) throws Exception { http.rememberMe(); }
然后我们进行测试,发现登录页多了一个记住我功能,我们登陆成功后关闭浏览器,再重新打开浏览器,用户依旧存在,不用再次登录。
那么这个功能是怎么实现的呢?
其实很简单,就是用户登录成功后,将cookie发送给浏览器保存,以后登录带上这个cookie,只要通过检查就可以免登录了(cookie默认保留14天)。如果点击注销,则会删除这个cookie。
首页定制
我们现在使用的登录页面时Security自带的,不是很美观,我们想要使用我们自己编写的界面,那么应该怎么做呢?
在登录页配置后面加上loginPage("/toLogin")
即可。即:http.formLogin().loginPage("/toLogin");
这时候的前端页面也应当指向我们自定义的login请求:
1 2 3 <a class ="item" th:href ="@{/toLogin}" > <i class ="address card icon" > </i > 登录 </a >
假设我们的前端的请求不是/toLogin
,而是/login
,那么应该怎么写呢?默认是/login
方式,但是默认的已经被我们自定义的取消的。我们可以这么写:
1 2 3 4 5 6 7 8 http.formLogin().loginPage("/toLogin" ).usernameParameter("username" ) .passwordParameter("password" ).loginProcessingUrl("/login" );
由于使用了自定义的登录界面,“记住我”功能也消失了,我们需要自定义,在配置类中编写:
1 2 http.rememberMe().rememberMeParameter("remember" );
同时在登录页添加记住我的组件:
1 <input type ="checkbox" name ="remember" > 记住我
然后进行测试即可!
1.6 代码节选
前端页面设计采用了SemanticUI进行编写。
完整配置代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/" ).permitAll() .antMatchers("/level1/**" ).hasAnyRole("vip1" ) .antMatchers("/level2/**" ).hasAnyRole("vip2" ) .antMatchers("/level3/**" ).hasAnyRole("vip3" ); http.formLogin().loginPage("/toLogin" ).usernameParameter("username" ) .passwordParameter("password" ).loginProcessingUrl("/login" ); http.csrf().disable(); http.logout ().logoutSuccessUrl("/" ); http.rememberMe().rememberMeParameter("remember" ); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder ()) .withUser("yang" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip2" , "vip3" ) .and() .withUser("root" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip1" , "vip2" , "vip3" ) .and() .withUser("guest" ).password(new BCryptPasswordEncoder ().encode("123456" )).roles("vip1" ); } }
前端登录界面节选:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <div class ="ui placeholder segment" > <div class ="ui column very relaxed stackable grid" > <div class ="column" > <div class ="ui form" > <form th:action ="@{/login}" method ="post" > <div class ="field" > <label > Username</label > <div class ="ui left icon input" > <input type ="text" placeholder ="Username" name ="username" > <i class ="user icon" > </i > </div > </div > <div class ="field" > <label > Password</label > <div class ="ui left icon input" > <input type ="password" name ="password" > <i class ="lock icon" > </i > </div > </div > <div class ="field" > <input type ="checkbox" name ="remember" > 记住我 </div > <input type ="submit" class ="ui blue submit button" /> </form > </div > </div > </div > </div >
2. Shiro
2.1 什么是Shiro
Shiro是一个Java安全(权限)框架
Shiro可以非常容易的开发出足够好的应用,不仅可以用于JavaSE环境,还可以用于JavaEE环境
Shiro可以完成认证、授权、加密、会话管理、Web集成、缓存等
官网:Shiro
2.2 有哪些功能
Authentication:身份认证、登录
Authorization:授权验证
Session Manage:会话管理,即用户登录后就是第一次会话,在没退出之前,他的所有信息都在会话中
Cryptography:加密
Web Support:Web支持
Caching:缓存
Concurrency:Shiro支持多线程应用的并发验证,即:在一个线程中开启另外一个线程,能把权限自动传播过去
Testing:提供测试支持
Run As:如果允许,可以使一个用户伪装成另一个用户登录
Remember Me:记住我
2.3 Shiro 快速开始
官方GitHub快速开始代码:ShiroQuickStart
官方快速开始文档:ShiroQuickStartDoc
根据官方文档,我们先创建一个Maven项目,然后倒入以下依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <dependency > <groupId > org.apache.shiro</groupId > <artifactId > shiro-core</artifactId > <version > 1.4.1</version > </dependency > <dependency > <groupId > org.slf4j</groupId > <artifactId > slf4j-simple</artifactId > <version > 1.7.21</version > </dependency > <dependency > <groupId > org.slf4j</groupId > <artifactId > jcl-over-slf4j</artifactId > <version > 1.7.21</version > </dependency > <dependency > <groupId > log4j</groupId > <artifactId > log4j</artifactId > <version > 1.2.17</version > </dependency >
将官方的 log4j.properties 文件拷贝至我们项目的resources目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 log4j.rootLogger =INFO, stdout log4j.appender.stdout =org.apache.log4j.ConsoleAppender log4j.appender.stdout.layout =org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern =%d %p [%c] - %m %n log4j.logger.org.apache =WARN log4j.logger.org.springframework =WARN log4j.logger.org.apache.shiro =INFO log4j.logger.org.apache.shiro.util.ThreadContext =WARN log4j.logger.org.apache.shiro.cache.ehcache.EhCache =WARN
将官方的 shiro.ini 文件拷贝至我们项目的resources目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 [users] root = secret, adminguest = guest, guestpresidentskroob = 12345 , presidentdarkhelmet = ludicrousspeed, darklord, schwartzlonestarr = vespa, goodguy, schwartz[roles] admin = *schwartz = lightsaber:*goodguy = winnebago:drive:eagle5
将将官方的 Quickstart.java 文件拷贝至我们项目的java目录下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 import org.apache.shiro.SecurityUtils;import org.apache.shiro.authc.*;import org.apache.shiro.ini.IniSecurityManagerFactory;import org.apache.shiro.mgt.SecurityManager;import org.apache.shiro.session.Session;import org.apache.shiro.subject.Subject;import org.apache.shiro.lang.util.Factory;import org.slf4j.Logger;import org.slf4j.LoggerFactory;public class Quickstart { private static final transient Logger log = LoggerFactory.getLogger(Quickstart.class); public static void main (String[] args) { Factory<SecurityManager> factory = new IniSecurityManagerFactory ("classpath:shiro.ini" ); SecurityManager securityManager = factory.getInstance(); SecurityUtils.setSecurityManager(securityManager); Subject currentUser = SecurityUtils.getSubject(); Session session = currentUser.getSession(); session.setAttribute("someKey" , "aValue" ); String value = (String) session.getAttribute("someKey" ); if (value.equals("aValue" )) { log.info("Retrieved the correct value! [" + value + "]" ); } if (!currentUser.isAuthenticated()) { UsernamePasswordToken token = new UsernamePasswordToken ("lonestarr" , "vespa" ); token.setRememberMe(true ); try { currentUser.login(token); } catch (UnknownAccountException uae) { log.info("There is no user with username of " + token.getPrincipal()); } catch (IncorrectCredentialsException ice) { log.info("Password for account " + token.getPrincipal() + " was incorrect!" ); } catch (LockedAccountException lae) { log.info("The account for username " + token.getPrincipal() + " is locked. " + "Please contact your administrator to unlock it." ); } catch (AuthenticationException ae) { } } log.info("User [" + currentUser.getPrincipal() + "] logged in successfully." ); if (currentUser.hasRole("schwartz" )) { log.info("May the Schwartz be with you!" ); } else { log.info("Hello, mere mortal." ); } if (currentUser.isPermitted("lightsaber:wield" )) { log.info("You may use a lightsaber ring. Use it wisely." ); } else { log.info("Sorry, lightsaber rings are for schwartz masters only." ); } if (currentUser.isPermitted("winnebago:drive:eagle5" )) { log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'. " + "Here are the keys - have fun!" ); } else { log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!" ); } currentUser.logout(); System.exit(0 ); } }
然后我们就可以运行并在控制台查看输出日志了。
如果我们直接拷贝官方的依赖,运行会报错,这时我们需要导入common-logging
依赖(Shiro默认使用的日志依赖),然后再次运行;运行后我们又发现控制台并没有按照我们的期望输出日志,这是因为我们的依赖拷贝的依赖中含有<scope>test</scope>
,我们需要将<scope>test</scope>
删除就可以输出期望的日志了。
2.4 Shiro的Subject分析
我们来分析一下快速开始中的Java代码。👊
首先,使用工厂模式解析配置文件 shiro.ini ,并创建 SecurityManager
对象:
1 2 3 Factory<SecurityManager> factory = new IniSecurityManagerFactory ("classpath:shiro.ini" ); SecurityManager securityManager = factory.getInstance(); SecurityUtils.setSecurityManager(securityManager);
获取当前的用户对象Subject:
1 Subject currentUser = SecurityUtils.getSubject();
通过当前用户获取Session(Shiro的Session,非Web中的Session):
1 2 3 4 5 6 Session session = currentUser.getSession();session.setAttribute("someKey" , "aValue" ); String value = (String) session.getAttribute("someKey" );if (value.equals("aValue" )) { log.info("Retrieved the correct value! [" + value + "]" ); }
判断当前的用户是否被认证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 if (!currentUser.isAuthenticated()) { UsernamePasswordToken token = new UsernamePasswordToken ("lonestarr" , "vespa" ); token.setRememberMe(true ); try { currentUser.login(token); } catch (UnknownAccountException uae) { log.info("There is no user with username of " + token.getPrincipal()); } catch (IncorrectCredentialsException ice) { log.info("Password for account " + token.getPrincipal() + " was incorrect!" ); } catch (LockedAccountException lae) { log.info("The account for username " + token.getPrincipal() + " is locked. " + "Please contact your administrator to unlock it." ); } catch (AuthenticationException ae) { } } log.info("User [" + currentUser.getPrincipal() + "] logged in successfully." );
角色检查:
1 2 3 4 5 if (currentUser.hasRole("schwartz" )) { log.info("May the Schwartz be with you!" ); } else { log.info("Hello, mere mortal." ); }
权限检查,粗粒度:
1 2 3 4 5 if (currentUser.isPermitted("lightsaber:wield" )) { log.info("You may use a lightsaber ring. Use it wisely." ); } else { log.info("Sorry, lightsaber rings are for schwartz masters only." ); }
权限检查(比第一次检查得更具体),细粒度:
1 2 3 4 5 6 if (currentUser.isPermitted("winnebago:drive:eagle5" )) { log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'. " + "Here are the keys - have fun!" ); } else { log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!" ); }
账户注销:
退出系统:
常用代码:
1 2 3 4 5 6 7 Subject currentUser = SecurityUtils.getSubject();Session session = currentUser.getSession();currentUser.isAuthenticated(); currentUser.getPrincipal(); currentUser.hasRole("schwartz" ); currentUser.isPermitted("lightsaber:wield" ); currentUser.logout();
2.5 SpringBoot 集成 Shiro
2.5.1 项目准备
创建SpringBoot项目,选择导入Web和Thymeleaf的启动器:
1 2 3 4 5 6 7 8 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-thymeleaf</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency >
编写一个简单的首页,用于测试环境是否搭建成功:
1 2 3 4 5 6 7 8 9 10 11 <!DOCTYPE html > <html lang ="en" xmlns:th ="https://www.thymeleaf.org/" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > 首页</h1 > <p th:text ="${msg}" > </p > </body > </html >
再编写一个简单的Controller,用于界面跳转:
1 2 3 4 5 6 7 8 9 @Controller public class MyController { @RequestMapping({"/","index"}) public String toIndex (Model model) { model.addAttribute("msg" ,"hello,shiro!" ); return "index" ; } }
最后,启动项目,访问:http://localhost:8080/
检查环境是否搭建成功!
补充:
Shiro三大对象回顾:
2.5.2 创建三大对象
导入整合所需依赖:
1 2 3 4 5 6 7 <dependency > <groupId > org.apache.shiro</groupId > <artifactId > shiro-spring</artifactId > <version > 1.5.0</version > </dependency >
创建包config
,在包下创建ShiroConfig.java
:
1 2 3 4 5 6 @Configuration public class ShiroConfig { }
我们需要倒着来进行创建,首先创建 realm
对象。在config包下创建自定义realm类UserRealm.java
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class UserRealm extends AuthorizingRealm { @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) { System.out.println("执行了=>授权doGetAuthorizationInfo" ); return null ; } @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { System.out.println("执行了=>认证doGetAuthenticationInfo" ); return null ; } }
PS: Shiro会先执行认证,再执行授权!
将自定义的UserRealm
类添加到Spring容器中(在ShiroConfig
类中编写):
1 2 3 4 5 @Bean(name = "userRealm") public UserRealm userRealm () { return new UserRealm (); }
创建 DefaultWebSecurityManager
:
1 2 3 4 5 6 7 8 9 @Bean(name = "securityManager") public DefaultWebSecurityManager getDefaultWebSecurityManager (@Qualifier("userRealm") UserRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager (); securityManager.setRealm(userRealm); return securityManager; }
创建 ShiroFilterFactoryBean
:
1 2 3 4 5 6 7 8 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean (); bean.setSecurityManager(defaultWebSecurityManager); return bean; }
2.5.3 页面拦截
编写两个前端页面:
add.html:
1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > ADD</h1 > </body > </html >
update.html:
1 2 3 4 5 6 7 8 9 10 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > UPDATE</h1 > </body > </html >
在控制器中编写跳转代码:
1 2 3 4 5 6 7 8 9 @RequestMapping("/user/add") public String add () { return "user/add" ; } @RequestMapping("/user/update") public String update () { return "user/update" ; }
在index.html中编写用于点击的超链接:
1 2 3 <a th:href ="@{/user/add}" > add</a > <a th:href ="@{/user/update}" > update</a >
在配置类中添加Shiro过滤器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean (); bean.setSecurityManager(defaultWebSecurityManager); Map<String, String> filterMap = new LinkedHashMap <>(); filterMap.put("/user/add" ,"authc" ); filterMap.put("/user/update" ,"authc" ); bean.setFilterChainDefinitionMap(filterMap); return bean; }
然后启动项目,运行测试,进入index界面后,我们点击界面上的add或update超链接,这个时候界面不会跳转至对应的界面,而是报错。这是因为我们进行的拦截设置,我们当前并没有权限。
但实际场景应该是:没有权限跳转至登录界面,而不是给用户报错。因此我们需要编写一个登录界面login.html
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <!DOCTYPE html > <html lang ="en" xmlns:th ="https://www.thymeleaf.org/" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > 登录</h1 > <hr > <p th:text ="${msg}" style ="color: red" > </p > <form th:action ="@{/login}" > <p > 用户名:<input type ="text" name ="username" > </p > <p > 密码:<input type ="password" name ="password" > </p > <p > <input type ="submit" value ="提交" > </p > </form > </body > </html >
添加跳转至登录界面的控制器:
1 2 3 4 @RequestMapping("/toLogin") public String toLogin () { return "login" ; }
在ShiroConfig配置类中设置登录的请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { filterMap.put("/user/*" ,"authc" ); bean.setFilterChainDefinitionMap(filterMap); bean.setLoginUrl("/toLogin" ); return bean; }
然后,我们可以进行测试,发现未登录时会跳转至登录界面。
2.5.4 用户认证
在控制器中编写登录逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @RequestMapping("/login") public String login (String username,String password,Model model) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken (username, password); try { subject.login(token); return "index" ; } catch (UnknownAccountException e) { model.addAttribute("msg" ,"用户名不存在!" ); return "login" ; }catch (IncorrectCredentialsException e){ model.addAttribute("msg" ,"密码不正确!" ); return "login" ; } }
在 UserRealm
类中编写用户认证逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { System.out.println("执行了=>认证doGetAuthenticationInfo" ); String username = "root" ; String password = "123456" ; UsernamePasswordToken userToken = (UsernamePasswordToken) token; if (!userToken.getUsername.equals(username)){ return null ; } return new SimpleAuthenticationInfo ("" ,password,"" ); }
2.5.5 整合数据库
导入依赖:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <dependency > <groupId > org.projectlombok</groupId > <artifactId > lombok</artifactId > </dependency > <dependency > <groupId > mysql</groupId > <artifactId > mysql-connector-java</artifactId > </dependency > <dependency > <groupId > log4j</groupId > <artifactId > log4j</artifactId > <version > 1.2.17</version > </dependency > <dependency > <groupId > com.alibaba</groupId > <artifactId > druid</artifactId > <version > 1.1.21</version > </dependency > <dependency > <groupId > org.mybatis.spring.boot</groupId > <artifactId > mybatis-spring-boot-starter</artifactId > <version > 2.1.1</version > </dependency >
编写全局配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 spring: datasource: username: root password: 123456 url: jdbc:mysql://localhost:3306/ssm?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8 driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource initialSize: 5 minIdle: 5 maxActive: 20 maxWait: 60000 timeBetweenEvictionRunsMillis: 60000 minEvictableIdleTimeMillis: 300000 validationQuery: SELECT 1 FROM DUAL testWhileIdle: true testOnBorrow: false testOnReturn: false poolPreparedStatements: true filters: stat,wall,log4j maxPoolPreparedStatementPerConnectionSize: 20 useGlobalDataSourceStat: true connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=500
在配置文件中配置MyBatis:
1 2 mybatis.type-aliases-package =com.yang.pojo mybatis.mapper-locations =classpath:mapper/*.xml
创建POJO(Account.java):
1 2 3 4 5 6 7 8 9 10 @Data @AllArgsConstructor @NoArgsConstructor public class Account { private int id; private String name; private float money; private String password; }
编写mapper接口:
1 2 3 4 5 6 @Repository @Mapper public interface AccountMapper { public Account queryAccountByName (String name) ; }
编写MyBatis映射文件:
1 2 3 4 5 6 7 8 9 10 11 <?xml version="1.0" encoding="utf-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace ="com.yang.mapper.AccountMapper" > <select id ="queryAccountByName" parameterType ="String" resultType ="Account" > select * from account where name = #{name} </select > </mapper >
编写Service层的接口和实现类:
1 2 3 public interface AccountService { public Account queryAccountByName (String name) ; }
1 2 3 4 5 6 7 8 9 10 11 @Service public class AccountServiceImpl implements AccountService { @Autowired AccountMapper accountMapper; @Override public Account queryAccountByName (String name) { return accountMapper.queryAccountByName(name); } }
改造UserRealm
类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public class UserRealm extends AuthorizingRealm { @Autowired AccountService accountService; @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) { System.out.println("执行了=>授权doGetAuthorizationInfo" ); return null ; } @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { System.out.println("执行了=>认证doGetAuthenticationInfo" ); UsernamePasswordToken userToken = (UsernamePasswordToken) token; Account account = accountService.queryAccountByName(userToken.getUsername()); if (account == null ){ return null ; } return new SimpleAuthenticationInfo ("" ,account.getPassword(),"" ); } }
2.5.6 MD5盐值加密密码
在上述案例中,我们只使用的简单的认证,即使是明文密码也可以通过认证,我们还可以设置其他的加密方法。
查看有哪些加密方法:
可以去 UserRealm
的父类 AuthorizingRealm
的父类 AuthenticatingRealm
中找一个方法。这个方法可以设置证书匹配器setCredentialsMatcher()
。我们进入接口CredentialsMatcher
,使用快捷键Ctrl + Alt + B
查看接口实现类:
我们看到有许多的实现类,这些实现类就提供了加密的方法。
❓那如果我想要更换加密方式,应该怎么做呢?
在配置类中创建一个bean:
1 2 3 4 5 6 7 8 9 @Bean public HashedCredentialsMatcher hashedCredentialsMatcher () { HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher (); hashedCredentialsMatcher.setHashAlgorithmName("md5" ); hashedCredentialsMatcher.setHashIterations(1 ); return hashedCredentialsMatcher; }
将创建的bean注册到realm中:
1 2 3 4 5 6 7 @Bean(name = "userRealm") public UserRealm userRealm () { UserRealm userRealm = new UserRealm (); userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); return userRealm; }
修改自定义的realm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { ByteSource salt = ByteSource.Util.bytes(account.getName()); SimpleHash simpleHash = new SimpleHash ("MD5" , account.getPassword(), salt); System.out.println(simpleHash.toString()); return new SimpleAuthenticationInfo (account,simpleHash,salt,"" ); }
然后我们可以重新启动项目,进行登录。测试后,可以登录成功,控制台也打印出加密后的密码:
1 dbb1c112a931eeb16299d9de1f30161d
我们可以使用MD5在线穷举破解( MD5不可逆 ,这只是穷举法破解出来的),得到解密后的密码:
我们发现,使用了盐值后,密码变成了 盐值 + 实际密码
的格式。😎
❓那么我们确定是真的被加密了呢?我们可以在下方位置打一个断点:
然后使用Debug模式启动项目,进入首页,点击登录,输入用户名和密码。在控制台中找到this
,点开它;找到credentialsMatcher
,点开它。我们可以发现,我们前面进行的配置都在这里显示了。如果使用最开始的方式,不进行加密,这里会是空的。💪
PS:
MD5在线加密:MD5在线加密
MD5在线穷举破解:MD5在线解密破解
2.5.7 用户拦截与授权
经过上面的测试,我们多次在控制台上看到以下信息:
1 2 执行了=>认证doGetAuthenticationInfo 执行了=>授权doGetAuthorizationInfo
表明 Shiro会先执行认证,再执行授权!
拦截
在ShiroConfig
类的ShiroFilterFactoryBean
中添加以下代码:
1 2 3 4 5 6 7 8 9 10 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { filterMap.put("/user/add" ,"perms[account:add]" ); filterMap.put("/user/update" ,"perms[account:update]" ); filterMap.put("/user/*" ,"authc" ); return bean; }
然后我们运行程序,并登陆,点击页面中的 add 或 update 链接,这时候页面会跳转至错误页面。状态码是401,显示未授权。
但是真实业务中,不应该跳转至错误页面,应该跳转至权限不足页面。因此,我们需要编写Controller(模拟跳转至错误页面):
1 2 3 4 5 @RequestMapping("/noauth") @ResponseBody public String unauthorized () { return "当前用户权限不足!" ; }
同时,需要在ShiroConfig
类的ShiroFilterFactoryBean
中设置权限不足界面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { filterMap.put("/user/add" ,"perms[account:add]" ); filterMap.put("/user/update" ,"perms[account:update]" ); filterMap.put("/user/*" ,"authc" ); bean.setUnauthorizedUrl("/noauth" ); return bean; }
然后,我们可以再进行测试。
运行程序,并登陆,点击页面中的 add 或 update 链接,这时候页面会跳转至我们设定的未授权界面。
授权
我们已经让某些界面需要权限才能够访问,那么应该怎么给用户赋予一些权限呢?
在UserRealm
类中授权方法中添加权限赋予代码:
1 2 3 4 5 6 7 8 9 10 11 12 @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) { System.out.println("执行了=>授权doGetAuthorizationInfo" ); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo (); info.addStringPermission("account:add" ); return info; }
运行项目,选择任一用户登录系统。这时候所有的用户都被赋予 add 权限,相当于系统的默认权限。同时,在实际的业务操作中,用户的权限信息是储存在数据库中的,并且不同用于拥有不同的权限。为了达到这个目的,我们需要在现有数据库的用户表中添加perms
字段,表示用户所拥有的对应权限。
然后,我们需要给我们的实体类增加一个属性,用来表示用户的权限:
1 2 3 4 5 6 7 8 9 10 11 @Data @AllArgsConstructor @NoArgsConstructor public class Account { private int id; private String name; private float money; private String password; private String perms; }
更改UserRealm
类中认证方法的返回值,使授权方法可以获取到当前用户:
1 return new SimpleAuthenticationInfo (account,account.getPassword(),"" );
更改UserRealm
类中授权方法的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) { System.out.println("执行了=>授权doGetAuthorizationInfo" ); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo (); info.addStringPermission("account:add" ); Subject subject = SecurityUtils.getSubject(); Account currentAccount = (Account) subject.getPrincipal(); info.addStringPermission(currentAccount.getPerms()); return info; }
最后,进行测试即可。
2.5.8 整合Thymeleaf
经过上面的编写与测试,我们发现用户如果没有 update 权限的情况下,进入首页也会显示 update 的链接。我们希望的应该是没有对应的权限,就不要显示对应的链接。这时候,就需要进行 Shiro 和 Thymeleaf 的整合!
首先,我们需要导入Shiro与Thymeleaf整合的依赖:
1 2 3 4 5 6 <dependency > <groupId > com.github.theborakompanioni</groupId > <artifactId > thymeleaf-extras-shiro</artifactId > <version > 2.0.0</version > </dependency >
然后在配置类ShiroConfig
中编写getShiroDialect()
并向容器中添加一个bean:
1 2 3 4 5 @Bean public ShiroDialect getShiroDialect () { return new ShiroDialect (); }
修改index.html前端代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <!DOCTYPE html > <html lang ="en" xmlns:th ="https://www.thymeleaf.org/" xmlns:shiro ="https://www.thymeleaf.org/thymeleaf-extras-shiro" > <head > <meta charset ="UTF-8" > <title > Title</title > </head > <body > <h1 > 首页</h1 > <div shiro:notAuthenticated > <a th:href ="@{/toLogin}" > 登录</a > </div > <hr > <p th:text ="${msg}" > </p > <div shiro:hasPermission ="account:add" > <a th:href ="@{/user/add}" > add</a > </div > <div shiro:hasPermission ="account:update" > <a th:href ="@{/user/update}" > update</a > </div > </body > </html >
最后,启动项目,测试即可。
在进行判断用户是否登录时,还可以从Session中进行取值判断。
更改前端代码:
1 2 3 4 <div th:if ="${session.loginAccount==null}" > <a th:href ="@{/toLogin}" > 登录</a > </div >
在UserRealm
类的认证方法中设置Session(这里的Session是Shiro的,不是Servlet的):
1 2 3 4 5 6 7 8 9 10 11 12 @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { System.out.println("执行了=>认证doGetAuthenticationInfo" ); Subject currentSubject = SecurityUtils.getSubject(); Session session = currentSubject.getSession(); session.setAttribute("loginAccount" ,account); }
同样可以启动项目并测试,会得到相同的结果。
2.6 重要代码
自定义realm,使用了MD5盐值加密。整合Thymeleaf时,采用从Session中获取值判断有无用户登录:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public class UserRealm extends AuthorizingRealm { @Autowired AccountService accountService; @Override protected AuthorizationInfo doGetAuthorizationInfo (PrincipalCollection principalCollection) { System.out.println("执行了=>授权doGetAuthorizationInfo" ); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo (); info.addStringPermission("account:add" ); Subject subject = SecurityUtils.getSubject(); Account currentAccount = (Account) subject.getPrincipal(); info.addStringPermission(currentAccount.getPerms()); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo (AuthenticationToken token) throws AuthenticationException { System.out.println("执行了=>认证doGetAuthenticationInfo" ); UsernamePasswordToken userToken = (UsernamePasswordToken) token; Account account = accountService.queryAccountByName(userToken.getUsername()); if (account == null ){ return null ; } Subject currentSubject = SecurityUtils.getSubject(); Session session = currentSubject.getSession(); session.setAttribute("loginAccount" ,account); ByteSource salt = ByteSource.Util.bytes(account.getName()); SimpleHash simpleHash = new SimpleHash ("MD5" , account.getPassword(), salt); System.out.println(simpleHash.toString()); return new SimpleAuthenticationInfo (account,simpleHash,salt,"" ); } }
Shiro配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @Configuration public class ShiroConfig { @Bean public ShiroFilterFactoryBean getShiroFilterFactoryBean (@Qualifier("securityManager") DefaultWebSecurityManager defaultWebSecurityManager) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean (); bean.setSecurityManager(defaultWebSecurityManager); Map<String, String> filterMap = new LinkedHashMap <>(); filterMap.put("/user/add" ,"perms[account:add]" ); filterMap.put("/user/update" ,"perms[account:update]" ); filterMap.put("/user/*" ,"authc" ); bean.setFilterChainDefinitionMap(filterMap); bean.setLoginUrl("/toLogin" ); bean.setUnauthorizedUrl("/noauth" ); return bean; } @Bean(name = "securityManager") public DefaultWebSecurityManager getDefaultWebSecurityManager (@Qualifier("userRealm") UserRealm userRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager (); securityManager.setRealm(userRealm); return securityManager; } @Bean(name = "userRealm") public UserRealm userRealm () { UserRealm userRealm = new UserRealm (); userRealm.setCredentialsMatcher(hashedCredentialsMatcher()); return userRealm; } @Bean public HashedCredentialsMatcher hashedCredentialsMatcher () { HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher (); hashedCredentialsMatcher.setHashAlgorithmName("md5" ); hashedCredentialsMatcher.setHashIterations(1 ); return hashedCredentialsMatcher; } @Bean public ShiroDialect getShiroDialect () { return new ShiroDialect (); } }