文章目录
前言
在 Web 开发中,安全一直是非常重要的一个方面。安全虽然属于应用的非功能性需求,但是应该在应用开发的初期就考虑进来。
如果在应用开发的后期才考虑安全的问题,就可能陷入一个两难的境地:
- 一方面,应用存在严重的安全漏洞,无法满足用户的要求,并可能造成用户的隐私数据被攻击者窃取;
- 另一方面,应用的基本架构已经确定,要修复安全漏洞,可能需要对系统的架构做出比较重大的调整,因而需要更多的开发时间,影响应用的发布进程。
本文以记录学习 shiro 为主,其它内容可能很潦草
我们先了解几个概念,再介绍几种解决方案
概念
无状态登录
什么是有状态?
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端 session 中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
- 服务端保存大量数据,增加服务端压力
- 服务端保存用户状态,无法进行水平扩展
- 客户端请求依赖服务端,多次请求必须访问同一台服务器
什么是无状态?
微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即:
- 服务端不保存任何客户端请求者信息
- 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
- 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩
- 减小服务端存储压力
如何实现无状态?
无状态登录的流程:
- 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
- 认证通过,将用户信息进行加密形成 token,返回给客户端,作为登录凭证
- 以后每次请求,客户端都携带认证的 token
- 服务的对 token 进行解密,判断是否有效。
常见的认证机制
- HTTP Basic Auth,是配合 RESTful API 使用的最简单的认证方式,只需提供用户名密码即可
- Cookie Auth,通过客户端带上来Cookie对象来与服务器端的session对象匹配来实现状态管理的
- OAuth,(开放授权)是一个开放的授权标准。
- OAuth允许用户提供一个令牌,让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容
- 适用于个人消费者类的互联网产品,如社交类APP等应用,但是不太适合拥有自有认证权限管理的企业应用。
- Token Auth,用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录
JWT 鉴权
什么是JWT ?
Json web token,JWT 是目前最流行的跨域认证解决方案。基于json的开放标准(RFC7用于519),以 token的方式代替传统的 session-cookie 模式,可实现无状态、分布式的Web应用授权。用于服务器,客户端传递信息签名验证。
JWT包含三部分数据:
Header:头部,通常头部有两部分信息:- 声明类型,这里是 JWT
我们会对头部进行base64编码,得到第一部分数据
Payload:载荷,就是有效数据,一般包含下面信息:- 用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
- 注册声明:如token的签发时间,过期时间,签发人等
这部分也会采用base64编码,得到第二部分数据
Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥(secret)(不要泄漏,最好周期性更换),通过加密算法生成。用于验证整个数据完整和可靠性
生成的数据格式:token==个人证件 jwt=个人身份证
传统的cookie-session鉴权:
客户端使用用户名和密码登录
服务器端验证账号密码通过后,在 session 里保存一些数据(比如说用户UID,登录时间等等)
服务器向用户返回一个 session_id,写入用户的cookie中
此后用户的每一次请求都用 把 cookie 中的这个 session_id 传给服务器
服务器接收到 session_id 找到之前保存的数据就可以知道用户有没有登录
传统方式的缺点:
- session通常放在内存中,用户数量如果过大会对服务器产生压力
- 扩展性。 哪怕session以文件形式保存,放在redis中。对于分布式系统来说会产生高流量的数据读取(文件同步读取问题)
- 容易受到 csrf 攻击
JWT 的验证方式
- 用户登录
- 服务的认证,通过后根据 secret 生成token
- 将生成的 token 返回给浏览器
- 用户每次请求携带 token
- 服务端利用公钥解读 jwt 签名,判断签名有效后,从 Payload 中获取用户信息
- 处理请求,返回响应结果
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了 Rest 的无状态规范。
JWT的优点:
服务器不用 session了,变为无状态。减小了开支
jwt 构成简单,占用很少的字节
json 格式通用。不用语言之间都可以处理
非对称加密
加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密文),其逆过程就是解码(解密),加密技术的要点是加密算法,加密算法可以分为三类:
- 对称加密,如 AES
- 基本原理:将明文分成N个组,然后使用密钥对各个组进行加密,形成各自的密文,最后把所有的分组密文进行合并,形成最终的密文。
- 优势:算法公开、计算量小、加密速度快、加密效率高
- 缺陷:双方都使用同样密钥,安全性得不到保证
- 非对称加密,如 RSA
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 私钥加密,持有私钥或公钥才可以解密
- 公钥加密,持有私钥才可解密
- 优点:安全,难以破解
- 缺点:算法比较耗时
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 不可逆加密,如 MD5,SHA
- 基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。
Spring中的拦截器
Spring为我们提供了org.springframework.web.servlet.handler.HandlerInterceptorAdapter这个适配器,继承此类,可以非常方便的实现自己的拦截器。
他有三个方法:
- 预处理 preHandle
- 后处理(调用了Service并返回 ModelAndView,但未进行页面渲染)、
- 返回处理(已经渲染了页面)
示意实现 JWT配置类写法:
@Component
public class JwtInterceptor extends HandlerInterceptorAdapter {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 1.通过request获取请求token信息
String authorization = request.getHeader("Authorization");
//判断请求头信息是否为空,或者是否已Bearer开头
if(!StringUtils.isEmpty(authorization) && authorization.startsWith("Bearer")) {
//获取token数据
String token = authorization.replace("Bearer ","");
//解析token获取claims
Claims claims = jwtUtils.parseJwt(token);
if(claims != null) {
//通过claims获取到当前用户的可访问API权限字符串
String apis = (String) claims.get("apis"); //api-user-delete,api-userupdate
//通过handler
HandlerMethod h = (HandlerMethod) handler;
//获取接口上的reqeustmapping注解
RequestMapping annotation =
h.getMethodAnnotation(RequestMapping.class);
//获取当前请求接口中的name属性
String name = annotation.name();
//判断当前用户是否具有响应的请求权限
if(apis.contains(name)) {
request.setAttribute("user_claims",claims);
return true;
}else {
throw new CommonException(ResultCode.UNAUTHORISE);
}
}
}
throw new CommonException(ResultCode.UNAUTHENTICATED);
}
}
SpringSecurity
Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架。它实际上是保护基于spring的应用程序的标准。对于安全控制,我们仅需要引入 spring-boot-starter-security 模块,进行少量的配置,即可实现强大的安全管理!
记住几个类:
WebSecurityConfigurerAdapter:自定义Security策略AuthenticationManagerBuilder:自定义认证策略@EnableWebSecurity:开启WebSecurity模式
Spring Security 的两个主要目标是 “认证” 和 “授权”(访问控制)。
“认证”(Authentication)
身份验证是关于验证您的凭据,如用户名/用户ID和密码,以验证您的身份。
身份验证通常通过用户名和密码完成,有时与身份验证因素结合使用。
在用户认证方面,Spring Security 框架支持主流的认证方式,包括 HTTP 基本认证、HTTP 表单验证、HTTP 摘要认证、OpenID 和 LDAP 等。
“授权” (Authorization)
授权发生在系统成功验证您的身份后,最终会授予您访问资源(如信息,文件,数据库,资金,位置,几乎任何内容)的完全权限。
在用户授权方面,Spring Security 提供了基于角色的访问控制和访问控制列表(Access Control List,ACL),可以对应用中的领域对象进行细粒度的控制。
这个概念是通用的,而不是只在Spring Security 中存在。
简单示例
这里介绍基本的登录登出认证操作,供入门了解
建议通过阅读源码练习,进入 对应的重写方法参数对象查看
参考官网:https://spring.io/projects/spring-security
参考官网:https://spring.io/projects/spring-security
- 引入 Spring Security 模块
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 非常简易的写个 Controller
@Controller
public class RouterController {
//首页
@RequestMapping({"/","/index"})
public String index(){
return "index";
}
@RequestMapping("/toLogin")
public String tologin(){
return "login";
}
@RequestMapping("/level1/{id}")
public String tologin(@PathVariable("id") int id){
return "views/level1"+id;
}
//...
}
编写 Spring Security 配置类
继承 WebSecurityConfigurerAdapter 类,重写 configure 方法
稍微提一下
该框架有一个很大的特点就是:链式编程
使用 HttpSecurity 对象,编写授权方法
http.formLogin();开启登录(该框架自动提供了登录页面,也可自己定义).loginPage("/toLogin"),自己定义登录页面
http.logout(),开启自动配置的注销http.rememberMe(),开启"记住我"功能.rememberMeParameter("remember"),
使用 AuthenticationManagerBuilder 对象 编写认证方法
- 在内存中定义,也可以在 jdbc 中去拿(示例为从内存中拿)
- Spring security 5.0中新增了多种加密方式,也改变了密码的格式。官方推荐的是使用 bcrypt 加密方式。
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//定制请求的授权规则
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/").permitAll()
.antMatchers("/level1/**").hasRole("vip1")
.antMatchers("/level2/**").hasRole("vip2")
.antMatchers("/level3/**").hasRole("vip3");
//开启自动配置的登录功能:如果没有权限,就会跳转到登录页面!
// /login 请求来到登录页
// /login?error 重定向到这里表示登录失败
http.formLogin()
.usernameParameter("username")//配置接收登录的用户名和密码的参数!
.passwordParameter("password")
.loginPage("/toLogin")
.loginProcessingUrl("/login"); // 登陆表单提交请求
//开启自动配置的注销的功能
// /logout 注销请求
// .logoutSuccessUrl("/"); 注销成功来到首页
// sample logout customization,这里也可以选择,清空cookie 与 session
//http.logout().deleteCookies("remove").invalidateHttpSession(false)
http.csrf().disable();//关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
http.logout().logoutSuccessUrl("/");
//记住我
//自定义接收前端参数!
http.rememberMe().rememberMeParameter("remember");
}
//定义认证规则
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//在内存中定义,也可以在jdbc中去拿....
//Spring security 5.0中新增了多种加密方式,也改变了密码的格式。
//要想我们的项目还能够正常登陆,需要修改一下configure中的代码。我们要将前端传过来的密码进行某种方式加密
//spring security 官方推荐的是使用bcrypt加密方式。
auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
.withUser("kuangshen").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","vip2");
}
}
上面代码示例了从内存中获取认证,下面截取使用数据库方式的官方文档参考:
import javax.sql.DataSource;
@Autowired
private DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username,password,enabled from users WHERE username=?")
.authoritiesByUsernameQuery("select username,authority from authorities where username=?")
.passwordEncoder(new BCryptPasswordEncoder());
}
记住我功能如何实现的呢?其实非常简单
登录成功后,将cookie发送给浏览器保存,以后登录带上这个cookie,只要通过检查就可以免登录了。如果点击注销,则会删除这个 cookie
我们可以查看浏览器的 cookie
如果注销404了,就是因为它默认防止csrf跨站请求伪造,因为会产生安全问题,我们可以将请求改为post表单提交,或者在spring security中关闭csrf功能;我们试试:在 配置中增加
http.csrf().disable();
http.csrf().disable();//关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
http.logout().logoutSuccessUrl("/");
- 示例 html 页面(使用 thymeleaf )
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5" 命名空间
sec:authorize="isAuthenticated()": 是否认证登录!来显示不同的页面
index 首页(部分)
<!--登录注销-->
<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">
<i class="address card icon"></i>
用户名:<span sec:authentication="principal.username"></span>
角色:<span sec:authentication="principal.authorities"></span>
</a>
</div>
<div sec:authorize="isAuthenticated()">
<a class="item" th:href="@{/logout}">
<i class="address card icon"></i> 注销
</a>
</div>
</div>
角色认证相关:
根据用户权限,动态显示菜单
<!-- sec:authorize="hasRole('vip1')" -->
<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>
login 页面 配置提交请求及方式
<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>
<input type="submit" class="ui blue submit button"/>
</form>
Shiro安全框架
什么是Shiro?
Apache Shiro是一个强大且易用的 Java 安全框架,执行身份验证、授权、密码和会话管理。使用Shiro的易于理解的 API ,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
与Spring Security的对比
Shiro较之 Spring Security,Shiro在保持强大功能的同时,还在简单性和灵活性方面拥有巨大优势。
- 易于理解的 Java Security API;
- 简单的身份认证(登录),支持多种数据源(LDAP,JDBC,Kerberos,ActiveDirectory 等);
- 对角色的简单的签权(访问控制),支持细粒度的签权;
- 支持一级缓存,以提升应用程序的性能;
- 内置的基于 POJO 企业会话管理,适用于 Web 以及非 Web 的环境;
- 异构客户端会话访问;
- 非常简单的加密 API;
- 不跟任何的框架或者容器捆绑,可以独立运行
Spring Security:
除了不能脱离Spring,shiro的功能它都有。而且 Spring Security对Oauth、OpenID也有支持,Shiro则需要自己手动实现。Spring Security的权限细粒度更高。
功能模块
Shiro可以非常容易的开发出足够好的应用,其不仅可以用在JavaSE环境,也可以用在JavaEE环境。Shiro可以帮助我们完成:认证、授权、加密、会话管理、与Web集成、缓存等。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-E5djzB6y-1590212278601)(https://s1.ax1x.com/2020/04/15/JCHHpQ.jpg)]
Authentication:身份认证/登录,验证用户是不是拥有相应的身份。Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情。Session Management:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通JavaSE环境的,也可以是如Web环境的。Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储。Web Support:Shiro 的 web 支持的 API 能够轻松地帮助保护 Web 应用程序。Caching:缓存,比如用户登录后,其用户信息、拥有的角色/权限不必每次去查,这样可以提高效率。Concurrency:Apache Shiro 利用它的并发特性来支持多线程应用程序。Testing:测试支持的存在来帮助你编写单元测试和集成测试,并确保你的能够如预期的一样安全。"Run As":一个允许用户假设为另一个用户身份(如果允许)的功能,有时候在管理脚本很有用。"Remember Me":记住我。
Shiro 架构
Subject:主体,可以看到主体可以是任何可以与应用交互的“用户”;SecurityManager:相当于 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher;是Shiro的心脏;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject 、且负责进行认证和授权、及会话、缓存的管理。Authenticator:认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;其需要认证策略(Authentication Strategy),即什么情况下算用户认证通过了;Authrizer:授权器,或者访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;Realm:可以有1个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是 JDBC 实现,也可以是 LDAP 实现,或者内存实现等等;由用户提供;注意:Shiro不知道你的用户/权限存储在哪及以何种格式存储;所以我们一般在应用中都需要实现自己的 Realm;SessionManager:如果写过 Servlet 就应该知道 Session 的概念,Session 呢需要有人去管理它的生命周期,这个组件就是 SessionManager;而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB 等环境;所有呢,Shiro 就抽象了一个自己的 Session 来管理主体与应用之间交互的数据;SessionDAO:DAO 大家都用过,数据访问对象,用于会话的 CRUD,比如我们想把 Session 保存到数据库,那么可以实现自己的 SessionDAO,通过如 JDBC 写到数据库;比如想把 Session 放到 Memcached中,可以实现自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能Cryptography:密码模块,Shiro提高了一些常见的加密组件用于如密码加密/解密的。
也就是说对于我们而言,最简单的一个 Shiro 应用:
- 应用代码通过 Subject 来进行认证和授权,而 Subject 又委托给 SecurityManager;
- 我们需要给 Shiro 的 SecurityManager 注入 Realm,从而让 SecurityManager 能得到合法的用户及其权限进行判断。
以上也可以看出,Shiro不提供维护用户/权限,而是通过 Realm 让开发人员自己注入。
执行流程分析
认证流程
首先调用
Subject.login(token)进行登录,其会自动委托给Security Manager,调用之前必须通过
SecurityUtils. setSecurityManager()设置;SecurityManager 负责真正的身份验证逻辑;它会委托给 Authenticator 进行身份验证;
Authenticator 才是真正的身份验证者,Shiro API中核心的身份认证入口点,此处可以自定义插入自己的实现;
Authenticator 可能会委托给相应的 AuthenticationStrategy 进行多Realm身份验证,默认ModularRealmAuthenticator 会调用 AuthenticationStrategy 进行多Realm身份验证;
Authenticator 会把相应的 token 传入Realm,从 Realm 获取身份验证信息,如果没有返回/抛出异常表示身份验证失败了。此处可以配置多个 Realm,将按照相应的顺序及策略进行访问。
授权流程
首先调用
Subject.isPermitted/hasRole接口,其会委托给 SecurityManager,而 SecurityManager接着会委托给 Authorizer;Authorizer 是真正的授权者,如果我们调用如
isPermitted(“user:view”),其首先会通 PermissionResolver 把字符串转换成相应的 Permission 实例;在进行授权之前,其会调用相应的 Realm 获取 Subject 相应的角色/权限用于匹配传入的角色/权限;
Authorizer 会判断 Realm 的角色/权限是否和传入的匹配,如果有多个 Realm,会委托给ModularRealmAuthorizer 进行循环判断,如果匹配如
isPermitted/hasRole会返回true,否则返回false表示授权失败。
示例
用户认证
认证:身份认证/登录,验证用户是不是拥有相应的身份。
基于shiro的认证,是通过 subject 的 login方法完成用户认证工作的。
认证的主要目的,比较用户输入的用户名密码是否和数据库中的一致
用户授权
授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限
授权的主要目的就是查询数据库获取用户的所有角色和权限信息
准备工作
- 参考依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.16</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!--shiro和spring整合-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.3.2</version>
</dependency>
<!--shiro核心包-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<version>1.3.2</version>
</dependency>
<!--shiro与redis整合-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
- 参考 yml
server:
port: 8081
spring:
application:
name: ihrm-company #指定服务名
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/shiro_db?useUnicode=true&characterEncoding=utf8
username: root
password: 111111
jpa:
database: MySQL
show-sql: true
open-in-view: true
redis:
host: 127.0.0.1
port: 6379
- 参考实体类
@Entity
@Table(name = "pe_role")
@Getter
@Setter
public class Role implements Serializable {
private static final long serialVersionUID = 594829320797158219L;
@Id
private String id;
private String name;
private String description;
//角色与用户 多对多
@ManyToMany(mappedBy="roles")
private Set<User> users = new HashSet<User>(0);
//角色与权限 多对多
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name="pe_role_permission",
joinColumns={@JoinColumn(name="role_id",referencedColumnName="id")},
inverseJoinColumns={@JoinColumn(name="permission_id",referencedColumnName="id")})
private Set<Permission> permissions = new HashSet<Permission>(0);
}
/**
* 用户实体类
*/
@Entity
@Table(name = "pe_user")
@Getter
@Setter
/**
* AuthCachePrincipal:
* redis和shiro插件包提供的接口
*/
public class User implements Serializable ,AuthCachePrincipal {
private static final long serialVersionUID = 4297464181093070302L;
/**
* ID
*/
@Id
private String id;
private String username;
private String password;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name="pe_user_role",joinColumns={@JoinColumn(name="user_id",referencedColumnName="id")},
inverseJoinColumns={@JoinColumn(name="role_id",referencedColumnName="id")}
)
private Set<Role> roles = new HashSet<Role>();//用户与角色 多对多
@Override
public String getAuthCacheKey() {
return null;
}
}
@Entity
@Table(name = "pe_permission")
@Getter
@Setter
@NoArgsConstructor
public class Permission implements Serializable {
private static final long serialVersionUID = -4990810027542971546L;
/**
* 主键
*/
@Id
private String id;
private String name;
private String code;
private String description;
}
- 参考启动类
@SpringBootApplication(scanBasePackages = "cn.cast")
@EntityScan("cn.cast.shiro.domain")
public class ShiroApplication {
public static void main(String[] args) {
SpringApplication.run(ShiroApplication.class, args);
}
//OpenEntityManagerInViewFilter会让session一直到view层调用结束后才关闭
@Bean
public OpenEntityManagerInViewFilter openEntityManagerInViewFilter() {
return new OpenEntityManagerInViewFilter();
}
}
登录 Controller
传统登录: 前端发送登录请求 => 接口部分获取用户名密码 => 程序员在接口部分手动控制
shiro:前端发送登录请求 => 接口部分获取用户名密码 => 通过 subject.login => realm域的认证方法
UserController
@RestController
public class UserController {
@Autowired
private UserService userService;
//个人主页
//使用shiro注解鉴权
//@RequiresPermissions() -- 访问此方法必须具备的权限
//@RequiresRoles() -- 访问此方法必须具备的角色
/**
* 1.过滤器:如果权限信息不匹配setUnauthorizedUrl地址
* 2.注解:如果权限信息不匹配,抛出异常
*/
@RequiresPermissions("user-home")
@RequestMapping(value = "/user/home")
public String home() {
return "访问个人主页成功";
}
//添加
@RequestMapping(value = "/user",method = RequestMethod.POST)
public String add() {
return "添加用户成功";
}
//查询
@RequestMapping(value = "/user",method = RequestMethod.GET)
public String find() {
return "查询用户成功";
}
//更新
@RequestMapping(value = "/user/{id}",method = RequestMethod.GET)
public String update(String id) {
return "更新用户成功";
}
//删除
@RequestMapping(value = "/user/{id}",method = RequestMethod.DELETE)
public String delete() {
return "删除用户成功";
}
/**
* 1.传统登录
* 前端发送登录请求 => 接口部分获取用户名密码 => 程序员在接口部分手动控制
* 2.shiro登录
* 前端发送登录请求 => 接口部分获取用户名密码 => 通过subject.login => realm域的认证方法
*
*/
//用户登录
@RequestMapping(value="/login")
public String login(String username,String password) {
//构造登录令牌
try {
/**
* 密码加密:
* shiro提供的md5加密
* Md5Hash:
* 参数一:加密的内容
* 111111 --- abcd
* 参数二:盐(加密的混淆字符串)(用户登录的用户名)
* 111111+混淆字符串
* 参数三:加密次数
*
*/
password = new Md5Hash(password,username,3).toString();
UsernamePasswordToken upToken = new UsernamePasswordToken(username,password);
//1.获取subject
Subject subject = SecurityUtils.getSubject();
//获取session
String sid = (String) subject.getSession().getId();
//2.调用subject进行登录
subject.login(upToken);
return "登录成功";
}catch (Exception e) {
return "用户名或密码错误";
}
}
}
Dao 与 service 示意
UserDao
/**
* 用户数据访问接口
*/
public interface UserDao extends JpaRepository<User, String>, JpaSpecificationExecutor<User> {
//根据手机号获取用户信息
User findByUsername(String name);
}
UserService
@Service
public class UserService {
@Autowired
private UserDao userDao;
public User findByName(String name) {
return this.userDao.findByUsername(name);
}
public List<User> findAll() {
return userDao.findAll();
}
}
异常类
BaseExceptionHandler
/**
* 自定义的公共异常处理器
* 1.声明异常处理器
* 2.对异常统一处理
*/
@ControllerAdvice
public class BaseExceptionHandler {
@ExceptionHandler(value = AuthorizationException.class)
@ResponseBody
public String error(HttpServletRequest request, HttpServletResponse response,AuthorizationException e) {
return "未授权";
}
}
自定义 Realm
自定义 Realm
Realm域:Shiro从Realm获取安全数据(如用户、角色、权限),就是说SecurityManager要验证用户身份,那么它需要从Realm获取相应的用户进行比较以确定用户身份是否合法;也需要从Realm得到用户相应的角色/权限进行验证用户是否能进行操作;可以把Realm看成DataSource,即安全数据源
- 需要继承 AuthorizingRealm 父类,重写父类中的两个方法
- doGetAuthorizationInfo,授权
- doGetAuthenticationInfo,认证
CustomRealm
/**
* 自定义的realm
*/
public class CustomRealm extends AuthorizingRealm {
public void setName(String name) {
super.setName("customRealm");
}
@Autowired
private UserService userService;
/**
* 授权方法
* 操作的时候,判断用户是否具有响应的权限
* 先认证 -- 安全数据
* 再授权 -- 根据安全数据获取用户具有的所有操作权限
*/
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//1.获取已认证的用户数据
User user = (User) principalCollection.getPrimaryPrincipal();//得到唯一的安全数据
//2.根据用户数据获取用户的权限信息(所有角色,所有权限)
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
Set<String> roles = new HashSet<>();//所有角色
Set<String> perms = new HashSet<>();//所有权限
for (Role role : user.getRoles()) {
roles.add(role.getName());
for (Permission perm : role.getPermissions()) {
perms.add(perm.getCode());
}
}
info.setStringPermissions(perms);
info.setRoles(roles);
return info;
}
/**
* 认证方法
* 参数:传递的用户名密码
*/
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//1.获取登录的用户名密码(token)
UsernamePasswordToken upToken = (UsernamePasswordToken) authenticationToken;
String username = upToken.getUsername();
String password = new String( upToken.getPassword());
//2.根据用户名查询数据库
User user = userService.findByName(username);
//3.判断用户是否存在或者密码是否一致
if(user != null && user.getPassword().equals(password)) {
//4.如果一致返回安全数据
//构造方法:安全数据,密码,realm域名
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user,user.getPassword(),this.getName());
return info;
}
//5.不一致,返回null(抛出异常)
return null;
}
public static void main(String[] args) {
System.out.println(new Md5Hash("123456","wangwu",3).toString());
}
}
会话管理
在 shiro 里所有的用户的会话信息都会由 Shiro 来进行控制,shiro 提供的会话可以用于 JavaSE/JavaEE 环境,不依赖于任何底层容器,可以独立使用,是完整的会话模块。通过 Shiro 的会话管理器(SessionManager)进行统一的会话管理
什么是 shiro 的会话管理?
SessionManager(会话管理器):管理所有 Subject 的 session 包括创建、维护、删除、失效、验证等工作。SessionManager是顶层组件,由SecurityManager管理
shiro提供了三个默认实现:
- DefaultSessionManager:用于JavaSE环境
- ServletContainerSessionManager:用于Web环境,直接使用servlet容器的会话。
- DefaultWebSessionManager:用于web环境,自己维护会话(自己维护着会话,直接废弃了Servlet容器的会话管理)。
Shiro结合redis的统一会话管理
自定义的 sessionManager
/**
* 自定义的sessionManager
*/
public class CustomSessionManager extends DefaultWebSessionManager {
/**
* 头信息中具有sessionid
* 请求头:Authorization: sessionid
*
* 指定sessionId的获取方式
*/
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
//获取请求头Authorization中的数据
String id = WebUtils.toHttp(request).getHeader("Authorization");
if(StringUtils.isEmpty(id)) {
//如果没有携带,生成新的sessionId
return super.getSessionId(request,response);
}else{
//返回sessionId;
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, "header");
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return id;
}
}
}
在配置中还需配置,实现思路,代码在下节
- 配置 shiro 的RedisManager,通过shiro-redis包提供的RedisManager统一对redis操作
- Shiro 内部有自己的本地缓存机制,为了更加统一方便管理,全部替换 redis 实现
- 配置SessionDao,使用 shiro-redis 实现的基于 redis 的sessionDao
- 配置会话管理器,指定sessionDao的依赖关系
- 统一交给 SecurityManager 管理
Shiro 的配置
SecurityManager 是 Shiro 架构的心脏,用于协调内部的多个组件完成全部认证授权的过程。例如通过调用 realm 完成认证与登录。使用基于 springboot的配置方式完成 SecurityManager,Realm 的装配
ShiroConfiguration
@Configuration
public class ShiroConfiguration {
//1.创建realm
@Bean
public CustomRealm getRealm() {
return new CustomRealm();
}
//2.创建安全管理器
@Bean
public SecurityManager getSecurityManager(CustomRealm realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm);
//将自定义的会话管理器注册到安全管理器中
securityManager.setSessionManager(sessionManager());
//将自定义的redis缓存管理器注册到安全管理器中
securityManager.setCacheManager(cacheManager());
return securityManager;
}
//3.配置shiro的过滤器工厂
/**
* 再web程序中,shiro进行权限控制全部是通过一组过滤器集合进行控制
*
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
//1.创建过滤器工厂
ShiroFilterFactoryBean filterFactory = new ShiroFilterFactoryBean();
//2.设置安全管理器
filterFactory.setSecurityManager(securityManager);
//3.通用配置(跳转登录页面,为授权跳转的页面)
filterFactory.setLoginUrl("/autherror?code=1");//跳转url地址
filterFactory.setUnauthorizedUrl("/autherror?code=2");//未授权的url
//4.设置过滤器集合
/**
* 设置所有的过滤器:有顺序map
* key = 拦截的url地址
* value = 过滤器类型
*
*/
Map<String,String> filterMap = new LinkedHashMap<>();
//filterMap.put("/user/home","anon");//当前请求地址可以匿名访问
//具有某中权限才能访问
//使用过滤器的形式配置请求地址的依赖权限
//filterMap.put("/user/home","perms[user-home]"); //不具备指定的权限,跳转到setUnauthorizedUrl地址
//使用过滤器的形式配置请求地址的依赖角色
//filterMap.put("/user/home","roles[系统管理员]");
filterMap.put("/user/**","authc");//当前请求地址必须认证之后可以访问
filterFactory.setFilterChainDefinitionMap(filterMap);
return filterFactory;
}
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
/**
* 1.redis的控制器,操作redis
*/
public RedisManager redisManager() {
RedisManager redisManager = new RedisManager();
redisManager.setHost(host);
redisManager.setPort(port);
return redisManager;
}
/**
* 2.sessionDao
*/
public RedisSessionDAO redisSessionDAO() {
RedisSessionDAO sessionDAO = new RedisSessionDAO();
sessionDAO.setRedisManager(redisManager());
return sessionDAO;
}
/**
* 3.会话管理器
*/
public DefaultWebSessionManager sessionManager() {
CustomSessionManager sessionManager = new CustomSessionManager();
sessionManager.setSessionDAO(redisSessionDAO());
return sessionManager;
}
/**
* 4.缓存管理器
*/
public RedisCacheManager cacheManager() {
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager());
return redisCacheManager;
}
//开启对shior注解的支持
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
shiro 过滤器
- anon, authc, authcBasic, user 是第一组认证过滤器,
- perms, port, rest, roles, ssl 是第二组授权过滤器,要通过授权过滤器,就先要完成登陆认证操作(即先要完成认证才能前去寻找授权) 才能走第二组授权器
- 例如访问需要 roles 权限的 url,如果还没有登陆的话,会直接跳转到
shiroFilterFactoryBean.setLoginUrl();设置的 url
授权
shiro 支持基于过滤器的授权方式也支持注解的授权方式
基于配置的授权:
//使用过滤器的形式配置请求地址的依赖角色
//filterMap.put("/user/home","roles[系统管理员]");
filterMap.put("/user/**","authc");//当前请求地址必须认证之后可以访问
基于注解的授权:
- RequiresPermissions,配置到方法上,表明执行此方法必须具有指定的权限
- RequiresRoles,配置到方法上,表明执行此方法必须具有指定的角色
//查询
@RequiresPermissions(value = "user-find")
public String find() {
return "查询用户成功";
}
//查询
@RequiresRoles(value = "系统管理员")
public String find() {
return "查询用户成功";
}
使用 Zuul 的过滤器
Zuul 作为网关的其中一个重要功能,就是实现请求的鉴权。而这个动作我们往往是通过Zuul提供的过滤器来实现的,这样我们就能实现 限流,灰度发布,权限控制 等等。
自定义过滤器
接下来,我们在Zuul编写拦截器,对用户的token进行校验,如果发现未登录,则进行拦截。
- 依赖导入,这里就省略了,示意一下
- 配置 yml
关注一下末尾的 filter ,并不是所有的路径我们都需要拦截,所以,我们需要在拦截时,配置一个白名单,如果在名单内,则不进行拦截。
server:
port: 10010
spring:
application:
name: leyou-gateway
eureka:
client:
registry-fetch-interval-seconds: 5
service-url:
defaultZone: http://127.0.0.1:10086/eureka
zuul:
prefix: /api # 路由路径前缀
routes:
item-service: /item/** # 商品微服务的映射路径
search-service: /search/** #路由到搜索微服务
user-service: /user/** # 用户微服务
auth-service: /auth/** # 授权中心微服务
cart-service: /cart/** # 购物车微服务
order-service: /order/** # 购物车微服务
add-host-header: true
sensitive-headers: # 覆盖默认敏感头信息
leyou:
jwt:
pubKeyPath: C:\\tmp\\rsa\\rsa.pub # 公钥地址
cookieName: LY_TOKEN # token
filter:
allowPaths:
- /api/auth
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
- 编写白名单的配置类
@Component
@ConfigurationProperties(prefix = "leyou.filter")
public class FilterProperties {
private List<String> allowPaths;
public List<String> getAllowPaths() {
return allowPaths;
}
public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}
- 编写 jwt 验证配置类
@Component
@ConfigurationProperties(prefix = "leyou.jwt")
public class JwtProperties {
private String pubKeyPath;// 公钥
private PublicKey publicKey; // 公钥
private String cookieName; //cookie
private static final Logger logger = LoggerFactory.getLogger(JwtProperties.class);
/**
* @PostContruct:在构造方法执行之后执行该方法
*/
@PostConstruct
public void init(){
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
logger.error("初始化公钥和私钥失败!", e);
throw new RuntimeException();
}
}
//省略 getter与setter
}
重头:配置过滤器
继承 ZuulFilter,重写里面方法
shouldFilter:返回一个Boolean值,判断该过滤器是否需要执行。返回true执行,返回false不执行。run:过滤器的具体业务逻辑。filterType:返回字符串,代表过滤器的类型。包含以下4种:pre:请求在被路由之前执行route:在路由请求时调用post:在route和errror过滤器之后调用error:处理请求时发生错误调用
filterOrder:通过返回的 int 值来定义过滤器的执行顺序,数字越小优先级越高。
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class LoginFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProperties;
@Autowired
private FilterProperties filterProperties;
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 10;
}
@Override
public boolean shouldFilter() {
//获取context
RequestContext context = RequestContext.getCurrentContext();
//获取request对象
HttpServletRequest request = context.getRequest();
//获取请求路径
String url = request.getRequestURL().toString();
//获取白名单
List<String> allowPaths = this.filterProperties.getAllowPaths();
// 判断白名单
// 遍历允许访问的路径
for (String allowPath : allowPaths) {
if (StringUtils.contains(url,allowPath)){
return false;
}
}
return true;
}
@Override
public Object run() throws ZuulException {
//获取context
RequestContext context = RequestContext.getCurrentContext();
//获取request对象
HttpServletRequest request = context.getRequest();
//获取token
String token = CookieUtils.getCookieValue(request, this.jwtProperties.getCookieName());
/*if (StringUtils.isBlank(token)){
// 校验出现异常,返回403
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}*/
try {
//解析
JwtUtils.getInfoFromToken(token,this.jwtProperties.getPublicKey());
} catch (Exception e) {
e.printStackTrace();
// 校验出现异常,返回403
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
}
HttpServletRequest request = context.getRequest();
//获取请求路径
String url = request.getRequestURL().toString();
//获取白名单
List<String> allowPaths = this.filterProperties.getAllowPaths();
// 判断白名单
// 遍历允许访问的路径
for (String allowPath : allowPaths) {
if (StringUtils.contains(url,allowPath)){
return false;
}
}
return true;
}
@Override
public Object run() throws ZuulException {
//获取context
RequestContext context = RequestContext.getCurrentContext();
//获取request对象
HttpServletRequest request = context.getRequest();
//获取token
String token = CookieUtils.getCookieValue(request, this.jwtProperties.getCookieName());
/*if (StringUtils.isBlank(token)){
// 校验出现异常,返回403
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}*/
try {
//解析
JwtUtils.getInfoFromToken(token,this.jwtProperties.getPublicKey());
} catch (Exception e) {
e.printStackTrace();
// 校验出现异常,返回403
context.setSendZuulResponse(false);
context.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
}
return null;
}
}