一、什么是web應用安全,為了安全我們要做哪些事情?
保護web資源不受侵害(資源:用戶信息、用戶財產、web數據信息等)
對訪問者的認證、授權,指定的用戶才可以訪問資源
訪問者的信息及操作得到保護(xss csrf sql注入等)
開發中我們需要注意的項:
1. 【高危】網絡關鍵數據傳輸加密
1. 【高危】站點使用https方式部署
2. 【高危】文件傳輸時,過濾與業務無關的文件類型
3. 【高危】接口開發,應預防泄露敏感數據
4. 【高危】預防url中帶url跳轉參數
5. 【中危】預防CSRF攻擊
6. 【中危】預防短信惡意重發
7. 【中危】預防暴力破解圖片驗證碼
8. 【低危】通過httponly預防xss盜取cookie信息
9. 【低危】設置http協議安全的報文頭屬性
......
二、為什么要聊spring security?
spring security在很多安全防護上很容易實現
理解spring security的抽象有助於養成面向對象思維
可以為理解spring security oauth2做鋪墊
三、先搞清楚兩大概念:認證、授權
Application security boils down to two more or less independent problems: authentication (who are you?) and authorization (what are you allowed to do?).(摘自spring官網 《Spring Security Architecture》)
簡單點出發,認證和授權可以理解為登錄和權限驗證
認證
首先試想一下我們自己實現登陸,通常需要做些什么?
1.輸入用戶名密碼、驗證碼提交給后端
2.用戶名密碼與數據庫的進行匹配驗證
3.驗證前可以先把用戶信息通過用戶名查出來,看看用戶狀態是否可用等
4.傳遞到后端的密碼可能需要加密后再與數據庫里的密碼進行匹配
5.數據庫里的密碼可能是加鹽存儲的,這樣我們傳遞進來的密碼還要進行加鹽加密,加鹽一般都是用戶表里的數據,即還是可能要提前通過用戶名查詢用戶信息(用戶名加鹽可以不用提前查庫)
6.登錄成功后,可以把用戶信息存儲到cookie,下次直接提取cookie信息進行登錄(即remember-me登錄)安全系數稍低,例如電商網站也可通過cookie登錄查看訂單列表,但是下單支付時還是要重新登錄
7.可能網站有多個登錄入口,多種登錄方式,用戶名密碼方式、短信驗證碼方式等
8.登錄成功后自動跳轉到主頁或者提示成功信息,登錄失敗跳轉到失敗頁或者提示失敗信息
9.退出登錄功能,清空session
簡單的進行抽象
認證攔截器、用戶信息服務
【認證攔截器】我們用戶名密碼的認證可以做成一個filter也可以做成一個servlet
1.提取用戶名密碼參數信息
2.通過用戶名獲取用戶信息,判斷用戶是否可用等
3.通過密碼加密器把密碼參數進行加密然后與查出來的密碼進行匹配,判斷是否認證通過
4.認證成功或者認證失敗的后續處理
【用戶信息服務】通過用戶名獲取用戶信息,但是這個只是一個方法,需要提供一個userservice來存放
問題:
這里只是單單考慮用戶名密碼登錄,如果現在要做手機號驗證碼登錄呢?再加一個認證攔截器?
這樣往后會衍生出很多個攔截器,如果是filter實現方式的話就會有多個filter,如果是servlet實現方式則會有多個servlet,如果從做成公用的中間件來提供使用的話,怎樣才是最好的方式?
認證提供者
如果是做成中間件的話,占用用戶的多個地址無疑是一個缺點
如果認證攔截器只使用一個,然后把認證這塊的業務進行打包,抽象出一個【認證提供者】,其子類有【用戶名密碼認證提供者 】、【手機號驗證碼認證提供者】供我們使用,手機號驗證碼認證提供者同樣可以使用【用戶信息服務】【認證失敗處理器】【認證成功處理器】
這樣是否會更好?
但是認證攔截器這里要做判斷,要通過請求參數來判斷使用哪種認證提供者
認證管理者
我們干脆把這個事情也抽象出來,讓【認證管理者】去做這個事情,如下:
認證攔截器只需要一個認證管理者,認證管理者可以有0-n個認證提供者,為什么可以0個呢,因為認證管理者本身也可以干認證這個事情,只不過他可以交給對應的認證提供者也可以自己干這個事情
與spring security對號入座
授權
什么是授權(權限驗證)?
授權即判斷用戶是否有訪問某資源的權限,資源對於我們web應用來說就是url,每一個controller里的action對應的request mapping的url
資源:web應用的每一個url
權限:用戶能夠訪問某個資源的憑證,可以是一個變量字符,也可以是角色名,是用戶與資源相關聯的中間產物
試想一下我們自己實現權限驗證,通常需要做些什么?
1.【授權攔截器】攔截需要授權的資源【受保護的資源】
2.【公開資源】放行靜態資源文件和不用授權的頁面,例如登錄界面
3.有些資源可以某個角色能夠訪問,有些資源需要某個權限才可以訪問
4.有些資源remember-me登錄的能訪問,有些資源必須重新輸入密碼登錄才能訪問,例5.如電商網站查看訂單就不用重新登錄,下單就需要,即划分了不同的安全級別
6.web界面上菜單按鈕的顯示與隱藏控制
授權攔截器
攔截所有請求還是只攔截受保護的資源請求?
方案一:只攔截受保護的資源請求
【授權攔截器】怎么攔截【受保護的資源】不攔截【公開資源】呢?
這還不簡單,在項目啟動時把受保護的資源動態添加到filter-mapping不就行了?
類似如下偽代碼:
這樣【公開資源】不被攔截、【受保護的資源】被攔截,一舉兩得!
但是問題來了,授權應用上線后運行正常,一段時間后,我們需要增加受保護的資源,比如子應用上線了,子應用是物理上另外一個單獨的應用,通過nginx掛載在同域名/module1目錄下,這時數據庫增加資源配置后,但是filter不生效,因為filter在應用啟動的時候已經注冊了,這里沒法增加urlpattern了,最簡單的辦法只能是重啟授權應用
方案二:攔截所有資源請求
【授權攔截器】攔截所有請求/*,當請求過來時,只需要判斷當前請求是否是【公開資源】(公開資源可以動態從配置取也可以從數據庫去取),是則直接放行,不在公開資源范圍則走授權流程
兩個方案對比來說,在不考慮性能消耗的情況下(也消耗不了多少性能),無疑方案二更安全更適合擴展
spring security也是采用的方案二,在攔截所有請求后,可以動態的加載受保護的資源配置,再進行處理
授權攔截器攔截到資源請求后,要做的就是授權
1.通過當前請求的資源獲取權限列表
2.獲取用戶的權限,我們需要從session持久化的地方去取用戶的權限信息,有統一的地方去存取,后面我們會講到,不在這里展開
循環資源的權限
循環用戶的權限
判斷用戶是否擁有該資源的權限
資源對應權限是1對1
那么我們的系統就簡單很多
直接通過資源獲取到權限,然后判斷用戶是否有該權限則可以判斷是否授權通過
資源對應權限是1對0
在用戶登錄情況下,我們是授權通過還是拒絕呢,這個取決於我們自己,可以通過配置去設定,spring security當然也是支持我們這么做的
FilterSecurityInterceptor.setRejectPublicInvocations(true) 默認是false
資源對應權限是1對多
那么授權這里我們應該需要這么干:
int hasAuthorities=0 循環資源的權限(5個) 循環用戶的權限,用戶有該資源權限則hasAuthorities +1
最后得到的結果是:
資源對應權限個數是5
用戶擁有權限個數是2
到底是能訪問呢還是不能訪問呢,即授權結果是通過還是不通過?
仔細看上圖其實發現ROLE_開頭的有兩個,是同一類型的權限,其他3個是不同類型,按道理一個正常用戶即是管理員又是新聞編輯角色的可能幾乎不可能,正常來說一個用戶只有一個角色,其他類型的權限也同理,如果按權限類型來分,應該是4類權限,那么用戶就擁有2類,應該是 4:2才對
所里這里涉及到兩個問題:
資源的權限應該按分類來進行計數(即ROLE開頭的歸為一類,不管資源擁有幾個,只要用戶有一個都計數1)
權限的分類:角色、操作、IP、認證模式等
授權的決策
一票通過,即用戶擁有一類權限即通過
全票通過,即用戶擁有所有權限分類才通過
少數服從多數,即用戶擁有的權限分類必須大於沒有的分類
例如對應上面的4:2,
一票通過:通過
全票通過:拒絕
少數服從多數:2=2 我們可以設置相同時的處理邏輯,通過或拒絕,spring security默認相同是通過
授權決策者、授權投票者
授權的決策我們交給【授權決策者】,投票我們交給【授權投票者】
與spring security對號入座
恭喜,你已經搞清楚filter chain中最關鍵的兩個filter了
Security filter chain: [
...
AuthenticationProcessingFilter
...
FilterSecurityInterceptor
]
四、spring security的filter chain
filter chain的概念
關於filter chain的概念我們就不做多的解釋,最下面的加載流程圖里也有說明
@EnableWebSecurity(debug = true)
把debug日志打出來后,每次請求都可以看到完整的filterchain,方便我們去理解和吸收
Security filter chain: [
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
LogoutFilter
AuthenticationProcessingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
]
如何使用filter chain中的filter
主要還是增加自己的實現,或者基於默認實現做一些配置 《Spring Security - Adding In Your Own Filters》
授之以漁比授之以魚更加重要,所以這里只是簡單的列舉一些使用的例子,具體的原理還是要到源碼中去自己品味摸索,每個filter自己的奧妙需要讀者自己去體會
AuthenticationProcessingFilter
登陸(認證 Authentication)
AuthenticationProcessingFilter =》默認UsernamePasswordAuthenticationFilter 或者配置自己實現的filter,登錄成功后會存儲到session,如果是使用的spring-session-redis則會存儲到redis
FilterSecurityInterceptor
權限驗證(授權Authorization)
FilterSecurityInterceptor=》替換成自己實現的filter 如果沒有則使用該filter
RememberMeAuthenticationFilter
protected void configure(HttpSecurity http) throws Exception { http.addFilterAt(rememberMeAuthenticationFilter(), RememberMeAuthenticationFilter.class) } private String REMEMBER_ME_KEY = "3a87d426-0789-46b1-91d9-61d1f953db17"; private RememberMeServices rememberMeServices() { return new TokenBasedRememberMeServices(REMEMBER_ME_KEY, customUserDetailService()) {{ setAlwaysRemember(true);//不需要前端傳遞參數 remember-me=(true/on/yes/1 四個值都可以) }}; } //這里可以跟認證的filter公用一個認證管理者(認證管理者會判斷當前authenticationRequest去判斷適用哪個provider),也可以建一個新的,然后只添加rememberme認證的provider private RememberMeAuthenticationFilter rememberMeAuthenticationFilter() throws Exception { return new RememberMeAuthenticationFilter(this.customAuthenticationManager(), this.rememberMeServices()); } private AuthenticationManager customAuthenticationManager() throws Exception { CustomDaoAuthenticationProvider authenticationProvider = new CustomDaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(this.customUserDetailService()); authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); List<AuthenticationProvider> providers = new ArrayList<>(); providers.add(authenticationProvider); providers.add(new RememberMeAuthenticationProvider(REMEMBER_ME_KEY)); return new ProviderManager(providers); }
RequestCacheAwareFilter
在security調用鏈中用戶可能在沒有登錄的情況下訪問被保護的頁面,這時候用戶會被跳轉到登錄頁,登錄之后,springsecurity會自動跳轉到之前用戶訪問的保護的頁面
SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler
會先從requestCache去取,如果有上面的操作(例如未登錄訪問某頁面,會記錄到session),就會取session獲得url,然后跳轉過去,如果requestcache取不到,就會執行
super.onAuthenticationSuccess,即SimpleUrlAuthenticationSuccessHandler的跳轉到登錄成功頁
如果有些業務數據是寫在登錄成功頁(例如寫cookie),那么如果requestcache有數據,則不會重定向到登錄成功頁,會直接跳轉到上次未登錄訪問的頁面url,到目標頁后則取不到登錄成功頁寫的數據,那么就會有問題
想要關閉訪問緩存?可以
一、全局配置里禁用掉
http.requestCache().requestCache(new NullRequestCache())
二、設置成功處理handler直接使用SimpleUrlAuthenticationSuccessHandler,
而不是SavedRequestAwareAuthenticationSuccessHandler
CustomUsernamePasswordAuthenticationFilter mu = new CustomUsernamePasswordAuthenticationFilter(); mu.setAuthenticationSuccessHandler(new SavedRequestAwareAuthenticationSuccessHandler(){{ setDefaultTargetUrl("/login/success"); }};);
把這里登錄成功的處理handler改為如下SimpleUrlAuthenticationSuccessHandler,simpleurl就不會去取requestCache
mu.setAuthenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler(){{ setDefaultTargetUrl("/login/success"); }};);
ConcurrentSessionFilter
protected void configure(HttpSecurity http) throws Exception { http .addFilterAt(new ConcurrentSessionFilter(this.sessionRegistry()),ConcurrentSessionFilter.class) } @Bean public SessionRegistry sessionRegistry(){ //如果是分布式系統,多台機器,這里還要改成SpringSessionBackedSessionRegistry使用springsession存儲,而不是存儲在內存里 return new SessionRegistryImpl(); } private CustomUsernamePasswordAuthenticationFilter loginFilter() throws Exception { CustomUsernamePasswordAuthenticationFilter mu = new CustomUsernamePasswordAuthenticationFilter(); List<SessionAuthenticationStrategy> list=new ArrayList(); //從內存取所有sessionid,並過期掉訪問時間最早的 list.add(new ConcurrentSessionControlAuthenticationStrategy(this.sessionRegistry()));//策略的先后順序沒有關系,spring會幫我們做好邏輯 //保存當前sessionid至內存 list.add(new RegisterSessionAuthenticationStrategy(this.sessionRegistry())); mu.setSessionAuthenticationStrategy(new CompositeSessionAuthenticationStrategy(list)); ... }
CsrfFilter
csrffilter默認不會攔截的請求類型:TRACE HEAD GET OPTIONS
protected void configure(HttpSecurity http) throws Exception { http.csrf().disable();//注釋掉默認的 http.addFilterAt(new CsrfFilter(csrfTokenRepository()),CsrfFilter.class); } //這里security默認開啟csrf配置也是一樣的,需要注意分布式環境時token的存儲問題 @Bean public CsrfTokenRepository csrfTokenRepository() { return new LazyCsrfTokenRepository(new HttpSessionCsrfTokenRepository()); } //自己的loginfilter默認SessionAuthenticationStrategy是null,所以自己實現filter需要注冊上去,如果是security默認的認證filter則會自動注入進去strategy不用我們操心 private CustomUsernamePasswordAuthenticationFilter loginFilter() throws Exception { CustomUsernamePasswordAuthenticationFilter mu = new CustomUsernamePasswordAuthenticationFilter(); List<SessionAuthenticationStrategy> list=new ArrayList(); //登錄成功后重新生成csrf token,否則登錄成功后token也不會變 list.add(new CsrfAuthenticationStrategy(csrfTokenRepository())); mu.setSessionAuthenticationStrategy(new CompositeSessionAuthenticationStrategy(list)); ... }
首先得get請求一個頁面,后台才會把token存到session供后面post時使用,不過這個csrftoken在訪問第一個get頁面后生成后都不會再改變了,需要注意這一點;
只有每次登錄成功后才會變!
AuthenticationProcessingFilter里面的SessionAuthenticationStrategy包含 CsrfAuthenticationStrategy. 會去設置新的csrftoken
如何使用token
csrfToken=((CsrfToken)ApplicationContextUtil.getBean(CsrfTokenRepository.class)).loadToken(request) csrfToken.getHeaderName() csrfToken.getParameterName() csrfToken.getToken() 或 ((CsrfToken)request.getAttribute("_csrf")).getHeaderName() ((CsrfToken)request.getAttribute("_csrf")).getParameterName() ((CsrfToken)request.getAttribute("_csrf")).getToken() 或 <meta name="_csrf" content="${_csrf.token}"/> <meta name="_csrf_header" content="${_csrf.headerName}"/> <script> var token = $("meta[name='_csrf']").attr("content"); var header = $("meta[name='_csrf_header']").attr("content"); $.ajaxSetup({ beforeSend: function (xhr) { if(header && token ){ xhr.setRequestHeader(header, token); } }} ); </script>
BasicAuthenticationFilter
該filter在ConcurrentSessionFilter后面,說明他不會走同時登錄次數限制的邏輯
構造UsernamePasswordAuthenticationToken然后調用authenticationManager進行身份認證
屬性里有RememberMeServices,說明可以走rememberme cookie自動登錄邏輯
LogoutFilter
logoutfilter 注意,只能post請求才可以
該filter會調用logouthandlers.logout
把 remembermeservices里的cookie設置過期
把 csrftokenrepository token設置為null
把 session.invalidate SecurityContext.setAuthentication((Authentication)null) SecurityContextHolder.clearContext();
ExceptionTranslationFilter
關於授權的所有異常拋出統一都是在ExceptionTranslationFilter
包括認證異常、授權異常
認證異常:指的是匿名或者未認證的用戶訪問了需要認證的資源
授權異常:當前用戶沒有訪問該資源的權限
protected void configure(HttpSecurity http) throws Exception { http.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(customAccessDeniedHandler); } @Component public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { protected final Log logger = LogFactory.getLog(getClass()); @Override public void commence(HttpServletRequest httpServletRequest, HttpServletResponse response, AuthenticationException ex) throws IOException, ServletException { logger.warn("請重新登錄后訪問,"+ex.getMessage()); logger.warn(JSONObject.toJSON(ex)); RespEntity respEntity = RespUtil.toRespEntity(RespUtil.ACCESS_DENIED, "請重新登錄后訪問",null); response.setCharacterEncoding("UTF-8"); response.setContentType("text/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.println( JSONObject.toJSONString(respEntity)); } } @Component public class CustomAccessDeniedHandler implements AccessDeniedHandler { protected final Log logger = LogFactory.getLog(getClass()); @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { logger.warn("請重新登錄后訪問,"+accessDeniedException.getMessage()); logger.warn(JSONObject.toJSON(accessDeniedException)); RespEntity respEntity = RespUtil.toRespEntity(RespUtil.ACCESS_DENIED, "請重新登錄后訪問", null); response.setCharacterEncoding("UTF-8"); response.setContentType("text/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.println( JSONObject.toJSONString(respEntity)); } }
關於session-fixation attacks
在登錄成功后要更換sessionid,默認的認證filter會幫我們加進去
private CustomUsernamePasswordAuthenticationFilter loginFilter() throws Exception {
CustomUsernamePasswordAuthenticationFilter mu = new CustomUsernamePasswordAuthenticationFilter(); List<SessionAuthenticationStrategy> list=new ArrayList(); //登錄成功后更換新的sessionid list.add(new ChangeSessionIdAuthenticationStrategy()); mu.setSessionAuthenticationStrategy(new CompositeSessionAuthenticationStrategy(list)); ... }
默認的認證filter的session認證strategy有4個(會隨着開啟csrf concurrentsession而增加strategy,不開則不加)
模擬:
建立一個springboot站點(不使用spring security)
@RestController
public class TestController { @GetMapping("/login") public String login(@RequestParam(name = "userName",required = false) String userName, HttpServletRequest request) { request.getSession().setAttribute("userName",userName); return "sessionid:"+request.getSession().getId()+";userName:"+userName; } @GetMapping("/user") public String getOrder( HttpServletRequest request) { return "sessionid:"+request.getSession().getId()+";userName:"+request.getSession().getAttribute("userName"); } }
1.攻擊者先訪問 login地址,得到sessionid
2.被攻擊者訪問地址
http://localhost:8080/login;jsessionid=520F92C885F099E997DA55D9D0F450BE
3.被攻擊者訪問地址后模擬get登錄(后面附帶參數)
http://localhost:8080/login;jsessionid=520F92C885F099E997DA55D9D0F450BE?userName=tianjun
4.攻擊者可以以用戶正常認證方式進行操作和竊取用戶信息
五、源碼相關
重要的還是搞清楚如何進行抽象,為什么這樣去抽象?
如下是spring bean加載流程(右鍵新標簽打開可查看大圖)