Spring Security從入門到實踐(一)小試牛刀

1、Spring Security簡介

打開Spring Security的官網,從其首頁的預覽上就能夠看見以下文字:java

Spring Security is a powerful and highly customizable authentication and access-control framework. It is the de-facto standard for securing Spring-based applications.

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements.git

這段文字的大體意思是:web

  • Spring Security是一個強大的、可高度定製化的身份驗證和訪問控制的框架,它基本上是保護基於Spring應用的安全標準。
  • Spring Security是一個專一於向Java應用程序提供身份驗證和受權的框架。像全部的Spring項目同樣,Spring Security的真正威力在於它能夠很容易地被擴展以知足定製需求。

身份驗證和訪問控制是應用安全的兩個重要方面,也經常被稱爲「認證」和「受權」。spring

  • 認證就是肯定主體的過程,當未認證的主體訪問系統資源的時候,系統會對主體的身份進行驗證,肯定該主體是否有合法的身份,不合法的主體將被應用拒絕訪問,這一點也很容易理解,好比某電商網站,未登陸的用戶是沒法訪問敏感數據資源的,好比訂單信息。
  • 受權是在主體認證結束後,判斷該認證主體是否有權限去訪問某些資源,沒有權限的訪問將被系統拒絕,好比某電商網站的登陸用戶去查看其它用戶的訂單信息,很明顯,系統會拒絕這樣的無理要求。

上面的兩點是應用安全的基本關注點,Spring Security存在的意義就是幫助開發者更加便捷地實現了應用的認證和受權能力。segmentfault

Spring Security的前身是Acegi Security,後來成爲了Spring在安全領域的頂級項目,並正式改名到Spring名下,成爲Spring全家桶中的一員,因此Spring Security很容易地集成到基於Spring的應用中來。Spring Security在用戶認證方面支持衆多主流認證標準,包括但不限於HTTP基本認證、HTTP表單驗證、HTTP摘要認證、OpenID和LDAP等,在用戶受權方面,Spring Security不只支持最經常使用的基於URL的Web請求受權,還支持基於角色的訪問控制(Role-Based Access Control,RBAC)以及訪問控制列表(Access Control List,ACL)等。後端

學習Spring Security不只僅是要學會如何使用,也要經過其設計精良的源碼來進行深刻地學習,學習它在認證與受權方面的設計思想,由於這些思想是能夠脫離具體語言,應用到其餘應用中。數組

本篇文章是連載系列文章:《Spring Security入門到實踐》的一個入門文章,後面將圍繞Spring Security進行深刻源碼解讀,作到不只會用,也知其因此然。瀏覽器

2、Spring Security的入門案例

咱們使用IntelliJ IDEA的Spring Initializr工具建立一個Spring Boot項目,在其pom文件中加入以下的經常使用依賴:安全

<dependencies>
        <!-- Spring Security的核心依賴 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- Spring Boot Web的核心依賴 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
</dependencies>

依賴添加完畢以後,再聲明一個index路由,返回一段文字:「Welcome to learn Spring Security!」,具體代碼以下所示:微信

@Slf4j
@Controller
@RequestMapping("/demo")
public class DemoController {

    @GetMapping
    @ResponseBody
    public String index() {
        return "Welcome to learn Spring Security!";
    }

}

此時就能夠啓動LearningSpringSecurityMainApplication的main方法,咱們的簡單應用就在8080端口啓動起來了,咱們在瀏覽器裏訪問http://localhost:8008/demo接口,按照原來的思路,那麼瀏覽器將接收到來自後端程序的問候:「Welcome to learn Spring Security!」,可是實際運行中,咱們發現,咱們訪問的接口被攔截了,要求咱們登陸後才能繼續訪問/demo路由,以下圖所示:

ncEBGQ.jpg

這是由於Spring Boot項目引入了Spring Security之後,自動裝配了Spring Security的環境,Spring Security的默認配置是要求通過了HTTP Basic認證成功後才能夠訪問到URL對應的資源,且默認的用戶名是user,密碼則是一串UUID字符串,輸出到了控制檯日誌裏,以下圖所示:

nrS55V.jpg

咱們在登陸窗口輸入用戶名和密碼後,就正確返回了「Welcome to learn Spring Security!」

很明顯,自動生成隨機密碼的方式並非最經常使用的方法,可是在學習階段,對於這種簡單的認證方式,也是須要進行研究的,對於HTTP Basic認證,咱們能夠在resources中的application.properties中進行配置用戶名和密碼:

# 配置用戶名和密碼
spring.security.user.name=user
spring.security.user.password=1234

配置了用戶名和密碼後,那麼再次啓動應用,咱們發如今控制檯中就沒有再生成新的隨機密碼了,使用咱們配置用戶名和密碼就能夠登陸並正確訪問到/demo路由了。

事實上,這種簡易的認證方式並不能知足企業級權限系統的要求,咱們須要根據企業的實際狀況開發出複雜的權限系統。雖然這種簡易方式並不經常使用,可是咱們也是須要了解其運行機制和原理,接下來,咱們一塊兒深刻了解這種基本方式運行原理。

3、Http Basic認證基本原理

HTTP Basic認證是一種較爲簡單的HTTP認證方式,客戶端經過將用戶名和密碼按照必定規則(用戶名:密碼)進行Base64編碼進行「加密」(可反向解密,等同於明文),將加密後的字符串添加到請求頭髮送到服務端進行認證的方式。可想而知,HTTP Basic是個不安全的認證方式,一般須要配合HTTPS來保證信息的傳輸安全。基本的時序圖以下所示:

file

咱們經過Postman來測試HTTP Basic的認證過程:

  • 第一步:不輸入用戶名和密碼進行Base64編碼,直接訪問/demo路由,返回結果以下圖所示:

nyVSRs.jpg

返回的結果顯示該路由的訪問前提條件是必須通過認證,沒有通過認證是訪問不到結果的,且咱們觀察返回頭中包含了WWW-Authenticate: Basic realm="Realm",若是在瀏覽器中,當瀏覽器檢測到返回頭中包含這個屬性,那麼會彈出一個要求輸入用戶名和密碼的對話框。返回頭的具體信息以下圖所示:

nyV8oD.jpg

  • 第二步:輸入用戶名和密碼或者自行經過Base64編碼工具加密字符串「user:1234」,將加密後的結果dXNlcjoxMjM0聯合Basic組成字符串「Basic dXNlcjoxMjM0」添加到請求頭屬性Authorization中訪問/demo路由,那麼將返回正確的結果。

nyVLlR.jpg

HTTP Basic的認證方式在企業級開發中不多使用,但也常見於一些中間件中,好比ActiveMQ的管理頁面,Tomcat的管理頁面等,都採用的HTTP Basic認證。

4、HTTP Basic認證在Spring Security中的應用

Spring Security在沒有通過任何配置的狀況下,默認也支持了HTTP Basic認證,整個Spring Security的基本原理就是一個攔截器鏈,以下圖所示:

這裏寫圖片描述

其中綠色部分的每一種過濾器表明着一種認證方式,主要工做檢查當前請求有沒有關於用戶信息,若是當前的沒有,就會跳入到下一個綠色的過濾器中,請求成功會打標記。綠色認證方式能夠配置,好比短信認證,微信。好比若是咱們不配置BasicAuthenticationFilter的話,那麼它就不會生效。

FilterSecurityInterceptor過濾器是最後一個,它會決定當前的請求可不能夠訪問Controller,判斷規則放在這個裏面。當不經過時會把異常拋給在這個過濾器的前面的ExceptionTranslationFilter過濾器。

ExceptionTranslationFilter接收到異常信息時,將跳轉頁面引導用戶進行認證。橘黃色和藍色的位置不可更改。當沒有認證的request進入過濾器鏈時,首先進入到FilterSecurityInterceptor,判斷當前是否進行了認證,若是沒有認證則進入到ExceptionTranslationFilter,進行拋出異常,而後跳轉到認證頁面(登陸界面)。

上面的簡單原理分析中提到,每個過濾器都是通過配置後纔會真正地生效,那麼默認的相關配置在哪裏呢?在Spring Security的官方文檔中提到了WebSecurityConfigurerAdapter類,HTTP相關的認證配置都在這個類的configure(HttpSecurity http)方法中,具體代碼以下:

protected void configure(HttpSecurity http) throws Exception {
        logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");

        http
            .authorizeRequests() // 攔截請求,建立了FilterSecurityInterceptor攔截器
                .anyRequest().authenticated() // 設置全部請求都得通過認證後才能夠訪問
                .and() // 用and來表示配置過濾器結束,以便進行下一個過濾器的建立和配置
            .formLogin() // 設置表單登陸,建立UsernamePasswordAuthenticationFilter攔截器
        .and()
            .httpBasic(); // 開啓HTTP Basic,建立BasicAuthenticationFilter攔截器
    }

這個方法中配置了三個攔截器,第一個是FilterSecurityInterceptor,第二個是基於表單登陸的UsernamePasswordAuthenticationFilter,第三個是基於HTTP Basic的BasicAuthenticationFilter,進入到authorizeRequests()、formLogin()、httpBasic()方法中,這三個方法的具體實現都在HttpSecurity類中,觀察三個方法的具體實現,分別建立了各自的配置類對象,分別是:ExpressionUrlAuthorizationConfigurer對象、FormLoginConfigurer對象以及HttpBasicConfigurer對象,這三個配置類有一個公共的父接口SecurityConfigurer,它有一個configure方法,每個子類都會去實現這個方法,從而在這個方法裏面配置各個攔截器(也並不是全部的攔截器都在configure方法中配置,好比UsernamePasswordAuthenticationFilter就是在構造方法中配置,後面會討論)以及其餘信息。本節將重點介紹BasicAuthenticationFilter,後面的文章中將繼續介紹其餘的認證方式。

咱們一塊兒來解讀一下HttpBasicConfigurer的configure方法,具體源碼以下所示:

@Override
public void configure(B http) throws Exception {
    AuthenticationManager authenticationManager = http
        .getSharedObject(AuthenticationManager.class);
    // 建立一個BasicAuthenticationFilter過濾器
    BasicAuthenticationFilter basicAuthenticationFilter = new BasicAuthenticationFilter(
        authenticationManager, this.authenticationEntryPoint);
    if (this.authenticationDetailsSource != null) {
        basicAuthenticationFilter
          .setAuthenticationDetailsSource(this.authenticationDetailsSource);
    }
    RememberMeServices rememberMeServices = http.getSharedObject(RememberMeServices.class);
    if (rememberMeServices != null) {
        basicAuthenticationFilter.setRememberMeServices(rememberMeServices);
    }
    basicAuthenticationFilter = postProcess(basicAuthenticationFilter);
    // 將當前的BasicAuthenticationFilter對象添加到攔截器鏈中
    http.addFilter(basicAuthenticationFilter);
}

建立對象以及設置部分其餘屬性也能夠在後面慢慢理解,那麼最後一行代碼將當前這個BasicAuthenticationFilter對象加入到了攔截器鏈中,咱們應該在此刻就要理解清楚。咱們都很清楚,做爲攔截器鏈,鏈中的每一個攔截器都是有前後順序的,那麼這個BasicAuthenticationFilter攔截器是如何加入到攔截器鏈中的呢?我進入到addFilter方法中一探究竟。

public HttpSecurity addFilter(Filter filter) {
        Class<? extends Filter> filterClass = filter.getClass();
        if (!comparator.isRegistered(filterClass)) {
            throw new IllegalArgumentException(
                    "The Filter class "
                            + filterClass.getName()
                            + " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
        }
    // 加入到攔截器列表中
        this.filters.add(filter);
        return this;
}

該addFilter方法在HttpSecurity類中,再加入到攔截器鏈以前,進行了一次檢測,判斷當前類型的攔截器是否已經註冊到了默認的攔截器鏈Map集合中,返回的結果是攔截器的順序值是否等於null的對比值。這個Map集合是以攔截器全限定類名爲鍵,攔截器順序值爲值,且默認起始攔截器順序爲100,每一個攔截器之間的順序值相隔100,這就爲攔截器先後添加其餘攔截器提供了預留位置,是一個很好的設計。

public boolean isRegistered(Class<? extends Filter> filter) {
        return getOrder(filter) != null;
}

上述代碼就是經過攔截器對象來獲取攔截器的順序值,而且與null相比,繼續進入到getOrder方法:

private Integer getOrder(Class<?> clazz) {
        while (clazz != null) {
            Integer result = filterToOrder.get(clazz.getName());
            if (result != null) {
                return result;
            }
            clazz = clazz.getSuperclass();
        }
        return null;
}

filterToOrder就是攔截器的Map集合,該集合中存儲了多種攔截器,並規定了攔截器的順序。由於BasicAuthenticationFilter類型的攔截器已經事先添加到了這個Map集合中,因此就返回了BasicAuthenticationFilter在整個攔截器鏈Map中的順序值,這樣isRegistered方法就會返回true,從而最後加入到了攔截器鏈中(攔截器鏈是一個List列表),這個Map集合中預先設置了多種攔截器,代碼以下所示:

FilterComparator() {
        Step order = new Step(INITIAL_ORDER, ORDER_STEP);
        put(ChannelProcessingFilter.class, order.next());
        put(ConcurrentSessionFilter.class, order.next());
        put(WebAsyncManagerIntegrationFilter.class, order.next());
        put(SecurityContextPersistenceFilter.class, order.next());
        put(HeaderWriterFilter.class, order.next());
        put(CorsFilter.class, order.next());
        put(CsrfFilter.class, order.next());
        put(LogoutFilter.class, order.next());
        filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter",
                order.next());
        put(X509AuthenticationFilter.class, order.next());
        put(AbstractPreAuthenticatedProcessingFilter.class, order.next());
        filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",
                order.next());
        filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter",
                order.next());
        put(UsernamePasswordAuthenticationFilter.class, order.next());
        put(ConcurrentSessionFilter.class, order.next());
        filterToOrder.put(
                "org.springframework.security.openid.OpenIDAuthenticationFilter", order.next());
        put(DefaultLoginPageGeneratingFilter.class, order.next());
        put(DefaultLogoutPageGeneratingFilter.class, order.next());
        put(ConcurrentSessionFilter.class, order.next());
        put(DigestAuthenticationFilter.class, order.next());
        filterToOrder.put(
                "org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter", order.next());
        put(BasicAuthenticationFilter.class, order.next());
        put(RequestCacheAwareFilter.class, order.next());
        put(SecurityContextHolderAwareRequestFilter.class, order.next());
        put(JaasApiIntegrationFilter.class, order.next());
        put(RememberMeAuthenticationFilter.class, order.next());
        put(AnonymousAuthenticationFilter.class, order.next());
        filterToOrder.put(
            "org.springframework.security.oauth2.client.web.OAuth2AuthorizationCodeGrantFilter",
                order.next());
        put(SessionManagementFilter.class, order.next());
        put(ExceptionTranslationFilter.class, order.next());
        put(FilterSecurityInterceptor.class, order.next());
        put(SwitchUserFilter.class, order.next());
}

這是從代碼層面解讀到了各個攔截器的具體順序,咱們從Spring Security的官方文檔中也能夠看到上述代碼所規定順序表,以下圖所示:

ncCJKK.jpg

上圖中並無列出全部的攔截器,從圖中咱們能夠看出,BasicAuthenticationFilter位於UsernamePasswordAuthenticationFilter以後,ExceptionTranslationFilter和FilterSecurityInterceptor順序與前面的Spring Security的基本原理圖保持了一致。

若是咱們建立的Filter沒有在預先設置的Map集合中,那麼就會拋出一個IllegalArgumentException異常,並提示咱們使用addFilterBefore或者addFilterAfter方法將自定義的攔截器加入到攔截器鏈中,這一提示頗有用,由於本系列文章後面會講到表單登陸原理的時候加入圖形驗證碼功能將用到這一特性(將圖形驗證碼的驗證攔截器加入到UsernamePasswordAuthenticationFilter以前)。

上面的內容都是解釋了BasicAuthenticationFilter是如何加入到攔截器鏈中的,屬於知識前置鋪墊,接下來咱們經過源碼分析BasicAuthenticationFilter是如何進行驗證的。

@Override
protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
                    throws IOException, ServletException {
        final boolean debug = this.logger.isDebugEnabled();
        // 從請求頭中獲取頭屬性爲Authorization的值
        String header = request.getHeader("Authorization");
        // 判斷請求頭中是否含有該屬性或者該屬性的值是不是以basic開頭的
        if (header == null || !header.toLowerCase().startsWith("basic ")) {
      // 說明不是HTTP Basic認證方式,因此進入到攔截器鏈的下一個攔截器中,本攔截器不做處理
            chain.doFilter(request, response);
            return;
        }

        try {
      // extractAndDecodeHeader是解碼Base64編碼後的字符串,獲取用戶名和密碼組成的字符數組
            String[] tokens = extractAndDecodeHeader(header, request);
            assert tokens.length == 2;
            // 數組的第一個值是用戶名,第二個值是密碼
            String username = tokens[0];

            if (debug) {
                this.logger
                        .debug("Basic Authentication Authorization header found for user '"
                                + username + "'");
            }
            // 判斷當前請求是否須要認證,具體的判斷標準能夠進入到authenticationIsRequired中查看,這裏簡單表述一下,這個方法的邏輯是:首先判斷Spring Security的上下文環境中是否存在當前用戶名對應的認證信息,若是沒有或者是有,可是沒有認證的,那麼就返回true,其次是認證信息是UsernamePasswordAuthenticationToken類型且認證信息的用戶名和傳入的用戶名不一致,那麼返回true,最後認證信息是AnonymousAuthenticationToken類型(匿名類型),那麼直接返回true,不然其餘狀況直接返回false,也就是無需再次認證。
            if (authenticationIsRequired(username)) {
        // 將用戶名和密碼封裝成UsernamePasswordAuthenticationToken,並標記爲未認證
                UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                        username, tokens[1]);
                authRequest.setDetails(
                        this.authenticationDetailsSource.buildDetails(request));
        // 調用認證管理器來進行認證操做,具體的認證步驟在ProviderManager類authenticate方法中,該方法首先是獲取Token的類型,這次認證的Token類型是UsernamePasswordAuthenticationToken,而後根據類型找到支持UsernamePasswordAuthenticationToken的Provider對象進行認證
                Authentication authResult = this.authenticationManager
                        .authenticate(authRequest);

                if (debug) {
                    this.logger.debug("Authentication success: " + authResult);
                }
                // 將認證成功後結果存儲到上下文環境中
                SecurityContextHolder.getContext().setAuthentication(authResult);
                // 「記住我」中設置記住當前認證信息,這個功能後期會重點介紹
                this.rememberMeServices.loginSuccess(request, response, authResult);
                // 認證成功後的一些處理,能夠自行實現
                onSuccessfulAuthentication(request, response, authResult);
            }

        }
    // 若是認證失敗,上述的authenticate方法會拋出異常表示認證失敗
        catch (AuthenticationException failed) {
      // 清除認證失敗的上下文環境
            SecurityContextHolder.clearContext();

            if (debug) {
                this.logger.debug("Authentication request for failed: " + failed);
            }
            // 「記住我」的認證失敗後的處理,後面會介紹
            this.rememberMeServices.loginFail(request, response);
            // 認證失敗後的處理,能夠自行實現
            onUnsuccessfulAuthentication(request, response, failed);
            // 是否忽略認證失敗,這裏默認爲false
            if (this.ignoreFailure) {
                chain.doFilter(request, response);
            }
            else {
        // 認證失敗後,默認會進入到這裏,從而調用到了BasicAuthenticationEntryPoint類中的commence方法,該方法的具體邏輯是在響應體中添加「WWW-Authenticate」的響應頭,並設置值爲Basic realm="Realm",這也就是用到了HTTP Basic的基本原理,當瀏覽器接收到響應以後,發現響應頭中包含WWW-Authenticate,就會彈出一個要求輸入用戶名和密碼的對話框,輸入用戶名和密碼後,若是正確,那麼就會訪問到具體的資源,不然會一直會彈出對話框
                this.authenticationEntryPoint.commence(request, response, failed);
            }

            return;
        }

        chain.doFilter(request, response);
}

上述的源碼中加入了詳細的解析,對每個重要步驟都進行了解說,上面提到,具體的認證過程用到了UsernamePasswordAuthenticationToken,這個屬於UsernamePasswordAuthenticationFilter的認證範疇,後面的文章將重點介紹(請持續關注個人Spring Security的源碼分析哦),這裏簡單說明一下:使用UsernamePasswordAuthenticationToken封裝的用戶名和密碼將由UsernamePasswordAuthenticationFilter來進行攔截認證,認證管理器拿到這個Token對象後,會從衆多的ProviderManager對象中選擇合適的manager來處理該Token,會將該用戶名和密碼與咱們在配置文件中配置的用戶名和密碼或者默認生成的UUID密碼進行匹配,若是匹配成功,那麼將返回認證成功的結果,這個結果將由FilterSecurityInterceptor判斷,它決定最後是否放行,是否容許當前請求訪問到/demo路由。

5、案例代碼說明

爲了方便交流,本篇文章以及後續的文章中涉及到的案例代碼都將託管到碼雲上,讀者能夠自行獲取。最新代碼都將在master分支上,《Spring Security入門到實踐》的每一篇文章都有對應的分支,後續文章都會體現每篇文章具體對應於哪個分支。因爲本人水平有限,源碼分析不免有不妥之處,歡迎批評指正。

代碼託管連接:https://gitee.com/itlemon/lea...
瞭解更多幹貨,歡迎關注個人微信公衆號:爪哇論劍(微信號:itlemon)
微信公衆號-爪哇論劍-itlemon

相關文章
相關標籤/搜索