文章目錄
前言
在 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;
}
}