身份認證持久化與會話管理

一旦您的應用實現了請求身份認證,那麼考慮如何持久化並恢復由此產生的身份認證狀態以供後續請求使用就顯得非常重要。

預設情況下,這是自動完成的,因此不需要額外的程式碼,但瞭解 HttpSecurity 中的 requireExplicitSave 意味著什麼仍然很重要。

如果您願意,您可以閱讀有關 requireExplicitSave 功能的更多資訊瞭解其重要性。否則,在大多數情況下,您已經完成了本節的學習。

但在離開之前,請考慮以下用例是否適用於您的應用

瞭解會話管理的元件

會話管理支援由幾個協同工作的元件組成,以提供功能。這些元件是SecurityContextHolderFilterSecurityContextPersistenceFilterSessionManagementFilter

在 Spring Security 6 中,預設情況下不設定 SecurityContextPersistenceFilterSessionManagementFilter。此外,任何應用都應僅設定 SecurityContextHolderFilterSecurityContextPersistenceFilter,不能同時設定兩者。

SessionManagementFilter

SessionManagementFilter 會對照 SecurityContextHolder 的當前內容檢查 SecurityContextRepository 的內容,以確定使用者在當前請求中是否已透過身份認證,這通常是透過非互動式身份認證機制實現的,例如預認證或 Remember Me [1]。如果 repository 包含安全上下文,則過濾器不做任何操作。如果 repository 不包含安全上下文,並且執行緒區域性的 SecurityContext 包含一個非匿名 Authentication 物件,則過濾器假定使用者已由棧中的前一個過濾器認證。然後它會呼叫配置的 SessionAuthenticationStrategy

如果使用者當前未認證,過濾器將檢查是否請求了無效的會話 ID(例如,由於超時),並在配置了 InvalidSessionStrategy 的情況下呼叫它。最常見的行為是重定向到固定 URL,這封裝在標準實現 SimpleRedirectInvalidSessionStrategy 中。後一種實現也用於透過名稱空間配置無效會話 URL,如前所述

棄用 SessionManagementFilter

在 Spring Security 5 中,預設配置依賴於 SessionManagementFilter 來檢測使用者是否剛剛認證並呼叫SessionAuthenticationStrategy。這樣做的問題在於,在典型設定下,每個請求都必須讀取 HttpSession

在 Spring Security 6 中,預設情況下身份認證機制本身必須呼叫 SessionAuthenticationStrategy。這意味著無需檢測 Authentication 何時完成,因此無需為每個請求讀取 HttpSession

棄用 SessionManagementFilter 時應考慮的事項

在 Spring Security 6 中,預設不使用 SessionManagementFilter,因此 sessionManagement DSL 中的某些方法將不再生效。

方法 替代方案

sessionAuthenticationErrorUrl

在您的身份認證機制中配置AuthenticationFailureHandler

sessionAuthenticationFailureHandler

在您的身份認證機制中配置AuthenticationFailureHandler

sessionAuthenticationStrategy

在您的身份認證機制中配置 SessionAuthenticationStrategy如上所述

如果您嘗試使用其中任何方法,將丟擲異常。

自定義身份認證資訊的儲存位置

預設情況下,Spring Security 會將安全上下文儲存在 HTTP 會話中。但是,您可能希望自定義它的原因有以下幾點

  • 您可能希望在 HttpSessionSecurityContextRepository 例項上呼叫單個 setter 方法

  • 您可能希望將安全上下文儲存在快取或資料庫中,以實現橫向擴充套件

首先,您需要建立一個 SecurityContextRepository 的實現或使用現有的實現(例如 HttpSessionSecurityContextRepository),然後您可以在 HttpSecurity 中進行設定。

自定義 SecurityContextRepository
  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    SecurityContextRepository repo = new MyCustomSecurityContextRepository();
    http
        // ...
        .securityContext((context) -> context
            .securityContextRepository(repo)
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    val repo = MyCustomSecurityContextRepository()
    http {
        // ...
        securityContext {
            securityContextRepository = repo
        }
    }
    return http.build()
}
<http security-context-repository-ref="repo">
    <!-- ... -->
</http>
<bean name="repo" class="com.example.MyCustomSecurityContextRepository" />

上述配置將 SecurityContextRepository 設定到 SecurityContextHolderFilter參與式身份認證過濾器中,例如 UsernamePasswordAuthenticationFilter。要在無狀態過濾器中也進行設定,請參見如何為無狀態身份認證自定義 SecurityContextRepository

如果您使用自定義身份認證機制,您可能希望自己儲存 Authentication

手動儲存 Authentication

在某些情況下,例如,您可能正在手動認證使用者,而不是依賴 Spring Security 過濾器。您可以使用自定義過濾器或Spring MVC 控制器端點來完成此操作。如果您想在請求之間儲存身份認證資訊(例如在 HttpSession 中),您必須這樣做

  • Java

private SecurityContextRepository securityContextRepository =
        new HttpSessionSecurityContextRepository(); (1)

@PostMapping("/login")
public void login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { (2)
    UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
        loginRequest.getUsername(), loginRequest.getPassword()); (3)
    Authentication authentication = authenticationManager.authenticate(token); (4)
    SecurityContext context = securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authentication); (5)
    securityContextHolderStrategy.setContext(context);
    securityContextRepository.saveContext(context, request, response); (6)
}

class LoginRequest {

    private String username;
    private String password;

    // getters and setters
}
1 SecurityContextRepository 新增到控制器中
2 注入 HttpServletRequestHttpServletResponse 以便能夠儲存 SecurityContext
3 使用提供的憑據建立一個未認證的 UsernamePasswordAuthenticationToken
4 呼叫 AuthenticationManager#authenticate 進行使用者認證
5 建立一個 SecurityContext 並設定其中的 Authentication 物件
6 SecurityContext 儲存到 SecurityContextRepository

就是這樣。如果您不確定上述示例中的 securityContextHolderStrategy 是什麼,可以在使用 SecurityContextStrategy 部分中閱讀更多相關資訊。

正確清除身份認證資訊

如果您使用 Spring Security 的登出支援,那麼它會為您處理很多事情,包括清除和儲存上下文。但是,假設您需要手動將使用者從應用中登出。在這種情況下,您需要確保正確地清除和儲存上下文

配置無狀態身份認證的持久化

有時沒有必要建立和維護 HttpSession 來在請求之間持久化身份認證資訊。一些身份認證機制,例如HTTP Basic,是無狀態的,因此會在每個請求中重新認證使用者。

如果您不希望建立會話,可以使用 SessionCreationPolicy.STATELESS,如下所示

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        // ...
        .sessionManagement((session) -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        // ...
        sessionManagement {
            sessionCreationPolicy = SessionCreationPolicy.STATELESS
        }
    }
    return http.build()
}
<http create-session="stateless">
    <!-- ... -->
</http>

上述配置配置 SecurityContextRepository使用 NullSecurityContextRepository,並且阻止請求被儲存在會話中

如果您使用 SessionCreationPolicy.NEVER,您可能會注意到應用仍然建立了 HttpSession。在大多數情況下,這是因為請求被儲存在會話中,以便在認證成功後用於再次請求已認證資源。要避免這種情況,請參考如何阻止請求被儲存部分。

在會話中儲存無狀態身份認證資訊

如果出於某種原因,您正在使用無狀態身份認證機制,但仍然希望將身份認證資訊儲存在會話中,您可以使用 HttpSessionSecurityContextRepository 而不是 NullSecurityContextRepository

對於HTTP Basic,您可以新增一個 ObjectPostProcessor 來修改 BasicAuthenticationFilter 使用的 SecurityContextRepository

HttpSession 中儲存 HTTP Basic 身份認證資訊
  • Java

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
    http
        // ...
        .httpBasic((basic) -> basic
            .addObjectPostProcessor(new ObjectPostProcessor<BasicAuthenticationFilter>() {
                @Override
                public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
                    filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
                    return filter;
                }
            })
        );

    return http.build();
}

上述內容也適用於其他身份認證機制,例如持有者令牌身份認證

理解 requireExplicitSave

在 Spring Security 5 中,預設行為是使用SecurityContextPersistenceFilterSecurityContext自動儲存到SecurityContextRepository中。儲存必須在提交 HttpServletResponse 之前和 SecurityContextPersistenceFilter 之前進行。不幸的是,自動持久化 SecurityContext 在請求完成之前(即恰好在提交 HttpServletResponse 之前)完成時,可能會讓使用者感到意外。跟蹤狀態以確定是否需要儲存也很複雜,有時會導致對 SecurityContextRepository(即 HttpSession)進行不必要的寫入。

由於這些原因,SecurityContextPersistenceFilter 已被棄用,並由 SecurityContextHolderFilter 替換。在 Spring Security 6 中,預設行為是SecurityContextHolderFilter只會從 SecurityContextRepository 讀取 SecurityContext 並將其填充到 SecurityContextHolder 中。現在,如果使用者希望 SecurityContext 在請求之間持久化,則必須使用 SecurityContextRepository 明確儲存 SecurityContext。這消除了歧義,並透過僅在必要時才寫入 SecurityContextRepository(即 HttpSession)來提高效能。

工作原理

總之,當 requireExplicitSavetrue 時,Spring Security 會設定SecurityContextHolderFilter而不是SecurityContextPersistenceFilter

配置併發會話控制

如果您希望限制單個使用者登入應用程式的能力,Spring Security 透過以下簡單的新增開箱即用地支援此功能。首先,您需要在配置中新增以下監聽器,以使 Spring Security 瞭解會話生命週期事件

  • Java

  • Kotlin

  • web.xml

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
open fun httpSessionEventPublisher(): HttpSessionEventPublisher {
    return HttpSessionEventPublisher()
}
<listener>
<listener-class>
    org.springframework.security.web.session.HttpSessionEventPublisher
</listener-class>
</listener>

然後在您的安全配置中新增以下行

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .maximumSessions(1)
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            sessionConcurrency {
                maximumSessions = 1
            }
        }
    }
    return http.build()
}
<http>
...
<session-management>
    <concurrency-control max-sessions="1" />
</session-management>
</http>

這將阻止使用者多次登入 - 第二次登入將導致第一次登入失效。

使用 Spring Boot,您可以透過以下方式測試上述配置場景

  • Java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class MaximumSessionsTests {

    @Autowired
    private MockMvc mvc;

    @Test
    void loginOnSecondLoginThenFirstSessionTerminated() throws Exception {
        MvcResult mvcResult = this.mvc.perform(formLogin())
                .andExpect(authenticated())
                .andReturn();

        MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession();

        this.mvc.perform(get("/").session(firstLoginSession))
                .andExpect(authenticated());

        this.mvc.perform(formLogin()).andExpect(authenticated());

        // first session is terminated by second login
        this.mvc.perform(get("/").session(firstLoginSession))
                .andExpect(unauthenticated());
    }

}

您可以使用最大會話示例進行嘗試。

通常您也可能希望阻止第二次登入,在這種情況下可以使用

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .maximumSessions(1)
            .maxSessionsPreventsLogin(true)
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            sessionConcurrency {
                maximumSessions = 1
                maxSessionsPreventsLogin = true
            }
        }
    }
    return http.build()
}
<http>
<session-management>
    <concurrency-control max-sessions="1" error-if-maximum-exceeded="true" />
</session-management>
</http>

第二次登入將被拒絕。“拒絕”是指如果使用基於表單的登入,使用者將被髮送到 authentication-failure-url。如果第二次認證透過其他非互動式機制發生,例如“remember-me”,則會向客戶端傳送“未經授權”(401)錯誤。如果您想改用錯誤頁面,可以將屬性 session-authentication-error-url 新增到 session-management 元素中。

使用 Spring Boot,您可以透過以下方式測試上述配置

  • Java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class MaximumSessionsPreventLoginTests {

    @Autowired
    private MockMvc mvc;

    @Test
    void loginOnSecondLoginThenPreventLogin() throws Exception {
        MvcResult mvcResult = this.mvc.perform(formLogin())
                .andExpect(authenticated())
                .andReturn();

        MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession();

        this.mvc.perform(get("/").session(firstLoginSession))
                .andExpect(authenticated());

        // second login is prevented
        this.mvc.perform(formLogin()).andExpect(unauthenticated());

        // first session is still valid
        this.mvc.perform(get("/").session(firstLoginSession))
                .andExpect(authenticated());
    }

}

如果您正在使用自定義的基於表單的登入認證過濾器,則必須顯式配置併發會話控制支援。您可以使用最大會話阻止登入示例進行嘗試。

檢測超時

會話本身會過期,並且不需要做任何事情來確保安全上下文被移除。話雖如此,Spring Security 可以檢測會話何時過期並執行您指定的特定操作。例如,當用戶使用已過期的會話發出請求時,您可能希望重定向到特定的端點。這透過 HttpSecurity 中的 invalidSessionUrl 實現

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .invalidSessionUrl("/invalidSession")
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            invalidSessionUrl = "/invalidSession"
        }
    }
    return http.build()
}
<http>
...
<session-management invalid-session-url="/invalidSession" />
</http>

請注意,如果您使用此機制檢測會話超時,則使用者在未關閉瀏覽器的情況下注銷並再次登入時,可能會錯誤地報告錯誤。這是因為在您使會話失效時,會話 Cookie 並未清除,即使使用者已登出,該 Cookie 也會被重新提交。如果是這種情況,您可能希望配置登出時清除會話 Cookie

自定義無效會話策略

invalidSessionUrl 是一個便利方法,用於使用SimpleRedirectInvalidSessionStrategy 實現設定 InvalidSessionStrategy。如果您想自定義行為,可以實現InvalidSessionStrategy介面,並使用 invalidSessionStrategy 方法進行配置

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .invalidSessionStrategy(new MyCustomInvalidSessionStrategy())
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            invalidSessionStrategy = MyCustomInvalidSessionStrategy()
        }
    }
    return http.build()
}
<http>
...
<session-management invalid-session-strategy-ref="myCustomInvalidSessionStrategy" />
<bean name="myCustomInvalidSessionStrategy" class="com.example.MyCustomInvalidSessionStrategy" />
</http>

您可以在登出時顯式刪除 JSESSIONID Cookie,例如在登出處理程式中使用Clear-Site-Data header

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .logout((logout) -> logout
            .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        logout {
            addLogoutHandler(HeaderWriterLogoutHandler(ClearSiteDataHeaderWriter(COOKIES)))
        }
    }
    return http.build()
}
<http>
<logout success-handler-ref="clearSiteDataHandler" />
<b:bean id="clearSiteDataHandler" class="org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler">
    <b:constructor-arg>
        <b:bean class="org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter">
            <b:constructor-arg>
                <b:list>
                    <b:value>COOKIES</b:value>
                </b:list>
            </b:constructor-arg>
        </b:bean>
    </b:constructor-arg>
</b:bean>
</http>

這樣做的好處是不依賴於特定的容器,並且適用於任何支援 Clear-Site-Data header 的容器。

作為替代方案,您也可以在登出處理程式中使用以下語法

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .logout(logout -> logout
            .deleteCookies("JSESSIONID")
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        logout {
            deleteCookies("JSESSIONID")
        }
    }
    return http.build()
}
<http>
  <logout delete-cookies="JSESSIONID" />
</http>

不幸的是,這不能保證適用於每個 servlet 容器,因此您需要在您的環境中進行測試。

如果您的應用執行在代理後面,您也可以透過配置代理伺服器來移除會話 Cookie。例如,使用 Apache HTTPD 的 mod_headers,以下指令透過使其過期來刪除對登出請求響應中的 JSESSIONID Cookie(假設應用部署在 /tutorial 路徑下)

<LocationMatch "/tutorial/logout">
Header always set Set-Cookie "JSESSIONID=;Path=/tutorial;Expires=Thu, 01 Jan 1970 00:00:00 GMT"
</LocationMatch>

有關Clear Site Data登出部分的更多詳細資訊。

理解會話固定攻擊防護

會話固定攻擊是一種潛在風險,惡意攻擊者可以透過訪問站點來建立一個會話,然後說服另一個使用者使用相同的會話登入(例如,透過傳送一個包含會話識別符號作為引數的連結給他們)。Spring Security 透過在使用者登入時建立新會話或更改會話 ID 來自動防範此攻擊。

配置會話固定防護

您可以透過選擇以下三種推薦選項來控制會話固定防護策略

  • changeSessionId - 不建立新會話。而是使用 Servlet 容器提供的會話固定防護(HttpServletRequest#changeSessionId())。此選項僅在 Servlet 3.1 (Java EE 7) 及更新版本的容器中可用。在舊版本容器中指定此選項將導致異常。這是 Servlet 3.1 及更新版本容器中的預設設定。

  • newSession - 建立一個新的“乾淨”會話,不復制現有會話資料(Spring Security 相關的屬性仍會被複制)。

  • migrateSession - 建立一個新會話並將所有現有會話屬性複製到新會話。這是 Servlet 3.0 或更舊版本容器中的預設設定。

您可以透過以下方式配置會話固定防護

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement((session) -> session
            .sessionFixation((sessionFixation) -> sessionFixation
                .newSession()
            )
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            sessionFixation {
                newSession()
            }
        }
    }
    return http.build()
}
<http>
  <session-management session-fixation-protection="newSession" />
</http>

發生會話固定防護時,會在應用上下文中釋出 SessionFixationProtectionEvent。如果您使用 changeSessionId,此防護也*會*導致任何 jakarta.servlet.http.HttpSessionIdListener 被通知,因此如果您的程式碼同時監聽這兩個事件,請謹慎使用。

您也可以將會話固定防護設定為 none 以停用它,但不推薦這樣做,因為它會使您的應用存在漏洞。

使用 SecurityContextHolderStrategy

考慮以下程式碼塊

  • Java

UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
        loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = this.authenticationManager.authenticate(token);
// ...
SecurityContext context = SecurityContextHolder.createEmptyContext(); (1)
context.setAuthentication(authentication); (2)
SecurityContextHolder.setContext(context); (3)
  1. 透過靜態訪問 SecurityContextHolder 建立一個空的 SecurityContext 例項。

  2. SecurityContext 例項中設定 Authentication 物件。

  3. SecurityContextHolder 中靜態設定 SecurityContext 例項。

雖然上述程式碼工作正常,但可能會產生一些不希望的效果:當元件透過 SecurityContextHolder 靜態訪問 SecurityContext 時,如果存在多個希望指定 SecurityContextHolderStrategy 的應用上下文,可能會產生競態條件。這是因為在 SecurityContextHolder 中,每個類載入器有一個策略,而不是每個應用上下文一個。

為了解決這個問題,元件可以從應用上下文中注入 SecurityContextHolderStrategy。預設情況下,它們仍然會從 SecurityContextHolder 查詢策略。

這些改動主要是在內部,但它們為應用提供了注入 SecurityContextHolderStrategy 而不是靜態訪問 SecurityContext 的機會。為此,您應該將程式碼更改為以下內容

  • Java

public class SomeClass {

    private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

    public void someMethod() {
        UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
                loginRequest.getUsername(), loginRequest.getPassword());
        Authentication authentication = this.authenticationManager.authenticate(token);
        // ...
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); (1)
        context.setAuthentication(authentication); (2)
        this.securityContextHolderStrategy.setContext(context); (3)
    }

}
  1. 使用配置的 SecurityContextHolderStrategy 建立一個空的 SecurityContext 例項。

  2. SecurityContext 例項中設定 Authentication 物件。

  3. SecurityContextHolderStrategy 中設定 SecurityContext 例項。

強制提前建立會話

有時,提前建立會話會很有價值。這可以透過使用ForceEagerSessionCreationFilter 來完成,它可以使用以下方式配置

  • Java

  • Kotlin

  • XML

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        sessionManagement {
            sessionCreationPolicy = SessionCreationPolicy.ALWAYS
        }
    }
    return http.build()
}
<http create-session="ALWAYS">

</http>

接下來閱讀什麼


[1] 透過認證後進行重定向的機制(例如基於表單的登入)進行的身份認證不會被 SessionManagementFilter 檢測到,因為該過濾器在認證請求期間不會被呼叫。在這些情況下,會話管理功能必須單獨處理。