You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

设置STATELESS后OAuth2授权请求仍带JSESSIONID Cookie问题咨询

问题描述

我已将Spring Security的SessionCreationPolicy设置为STATELESS,期望所有请求场景都不会返回Set-Cookie头,但在OAuth2流程正常运行的情况下,http://authorize-server.com/...authorize的302响应仍带有JSESSIONID Cookie。我计划通过ResourceServer+JWT实现认证,后续流程无需会话,有两个疑问:

  1. 若OAuth2流程确实需要JSESSIONID,是否需要在/api/oauth2/endpoint手动结束会话?
  2. OAuth2流程中的state参数已可确定请求来源,为何还要使用Cookie+JSESSIONID?
问题解答

1. STATELESS下仍出现JSESSIONID的原因

尽管配置了SessionCreationPolicy.STATELESS,但OAuth2 Login流程默认依赖服务器端会话存储授权请求的上下文信息——包括state、nonce(OpenID Connect场景)、客户端ID、重定向URI等元数据。在重定向到授权服务器前,Spring Security会将这些信息存入会话,因此会生成JSESSIONID Cookie。

即使配置了NullSecurityContextRepository,OAuth2 Login的核心过滤器OAuth2AuthorizationRequestRedirectFilter仍会使用会话保存授权请求,这是默认实现的固有行为。

2. 是否需要手动结束会话?

如果后续完全基于JWT运行、不需要服务器端会话,建议在OAuth2登录完成的回调端点/api/oauth2/endpoint手动销毁会话,避免无用Cookie残留。有两种实现方式:

方式一:在回调接口中添加销毁逻辑

@GetMapping("/api/oauth2/endpoint")
fun callback(request: HttpServletRequest, response: HttpServletResponse): String {
    // 处理登录后的业务逻辑(比如生成JWT返回给前端)
    // 销毁服务器端会话
    request.session.invalidate()
    // 清除客户端JSESSIONID Cookie
    val cookie = Cookie("JSESSIONID", null)
    cookie.path = "/"
    cookie.maxAge = 0
    response.addCookie(cookie)
    return "登录成功"
}

方式二:通过Spring Security登录成功处理器配置

.oauth2Login {
    // 其他原有配置...
    it.successHandler { request, response, authentication ->
        // 销毁会话
        request.session.invalidate()
        // 清除Cookie
        val cookie = Cookie("JSESSIONID", null)
        cookie.path = "/"
        cookie.maxAge = 0
        response.addCookie(cookie)
        // 跳转到目标页面或返回响应
        response.sendRedirect("/api/oauth2/endpoint")
    }
}

3. state参数与Cookie+JSESSIONID的区别

state参数的核心作用是防止CSRF攻击,仅用于验证回调请求与发起的授权请求是否匹配,但它不存储完整的授权上下文信息。而Cookie+JSESSIONID的作用是:

  • 存储授权请求的完整元数据(如客户端ID、scope、重定向URI等),回调时需要这些信息匹配state并向授权服务器请求令牌;
  • 避免敏感上下文通过URL传递(URL长度有限且易被日志记录);
  • OpenID Connect场景下,还会存储nonce参数防止ID令牌重放攻击。

两者职责不同,state是短验证字符串,会话是上下文存储容器,无法互相替代。

4. 进阶优化:让OAuth2 Login摆脱服务器端会话

如果想彻底避免生成JSESSIONID,可以自定义OAuth2AuthorizationRequestRepository,将授权上下文存储到前端加密Cookie中,而非服务器端会话。示例实现:

class CookieOAuth2AuthorizationRequestRepository : OAuth2AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    private val AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"
    private val STATE_PARAM_COOKIE_NAME = "oauth2_state"
    private val cookieExpireSeconds = 180

    override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? {
        return getCookie(request, AUTHORIZATION_REQUEST_COOKIE_NAME)
            ?.let { cookie ->
                ObjectMapper().readValue(URLDecoder.decode(cookie.value, StandardCharsets.UTF_8), OAuth2AuthorizationRequest::class.java)
            }
    }

    override fun saveAuthorizationRequest(authorizationRequest: OAuth2AuthorizationRequest?, request: HttpServletRequest, response: HttpServletResponse) {
        if (authorizationRequest == null) {
            clearCookies(request, response)
            return
        }
        val cookieValue = URLEncoder.encode(ObjectMapper().writeValueAsString(authorizationRequest), StandardCharsets.UTF_8)
        val authCookie = Cookie(AUTHORIZATION_REQUEST_COOKIE_NAME, cookieValue)
        authCookie.path = "/"
        authCookie.maxAge = cookieExpireSeconds
        authCookie.isHttpOnly = true
        authCookie.secure = request.isSecure

        val stateCookie = Cookie(STATE_PARAM_COOKIE_NAME, authorizationRequest.state)
        stateCookie.path = "/"
        stateCookie.maxAge = cookieExpireSeconds
        stateCookie.isHttpOnly = true
        stateCookie.secure = request.isSecure

        response.addCookie(authCookie)
        response.addCookie(stateCookie)
    }

    override fun removeAuthorizationRequest(request: HttpServletRequest, response: HttpServletResponse): OAuth2AuthorizationRequest? {
        return loadAuthorizationRequest(request).also {
            clearCookies(request, response)
        }
    }

    private fun clearCookies(request: HttpServletRequest, response: HttpServletResponse) {
        listOf(AUTHORIZATION_REQUEST_COOKIE_NAME, STATE_PARAM_COOKIE_NAME).forEach { cookieName ->
            getCookie(request, cookieName)?.let {
                it.maxAge = 0
                it.path = "/"
                response.addCookie(it)
            }
        }
    }

    private fun getCookie(request: HttpServletRequest, name: String): Cookie? {
        return request.cookies?.firstOrNull { it.name == name }
    }
}

然后在SecurityFilterChain中配置使用该Repository:

.oauth2Login {
    // 其他原有配置...
    it.authorizationEndpoint {
        it.authorizationRequestRepository(CookieOAuth2AuthorizationRequestRepository())
    }
}

配置后Spring Security将不再创建服务器端会话,而是通过加密Cookie存储授权上下文,也就不会生成JSESSIONID了。

原始配置代码
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http
        .csrf { it.disable() }
        .formLogin { it.disable() }
        .logout { it.disable() }
        .httpBasic { it.disable() }
        .anonymous { it.disable() }
        .oauth2ResourceServer { it.jwt { } }
        .cors { }
        .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
        .requestCache { it.requestCache(NullRequestCache()) }
        .securityContext {
            it.securityContextRepository(NullSecurityContextRepository())
            it.requireExplicitSave(true)
        }
        .authorizeHttpRequests {
            it.requestMatchers("/api/oauth2/endpoint").permitAll()
            it.requestMatchers("/api/ping").permitAll()
            it.anyRequest().authenticated()
        }
        .oauth2Login {
            it.authorizationEndpoint {
                it.authorizationRequestResolver(
                    oAuth2AuthorizationRequestResolver(
                        registrationRepository,
                        oAuth2AuthorizationRequestCustomizer
                    )
                )
            }
            it.tokenEndpoint {
                it.accessTokenResponseClient(
                    oAuth2AccessTokenResponseClient(
                        oAuth2AuthorizationCodeGrantRequestEntityConverter,
                        mapOAuth2AccessTokenResponseConverter
                    )
                )
            }
            it.userInfoEndpoint {
                it.userService(oAuth2UserService)
            }
            it.defaultSuccessUrl("/api/oauth2/endpoint", false)
            it.failureHandler { request, response, exception ->
                exception.printStackTrace()
            }
        }
    return http.build()
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
    val configuration = CorsConfiguration()
    configuration.allowedOriginPatterns = mutableListOf("*")
    configuration.allowedMethods = mutableListOf("*")
    configuration.allowedHeaders = mutableListOf("*")
    configuration.allowCredentials = true
    val source = UrlBasedCorsConfigurationSource()
    source.registerCorsConfiguration("/**", configuration)
    return source
}

@Bean
fun decoder(): JwtDecoder {
    val originalKey = "b0f29fc0d32efdbabff03d4aae352b4936e69b0c3c6b8a0b067ae2453f96b431".toByteArray()
    val secretKeySpec = SecretKeySpec(originalKey, "HmacSHA256")
    return NimbusJwtDecoder.withSecretKey(secretKeySpec).build()
}

@Bean
fun encoder(): JwtEncoder {
    val originalKey = "b0f29fc0d32efdbabff03d4aae352b4936e69b0c3c6b8a0b067ae2453f96b431".toByteArray()
    val secretKeySpec = SecretKeySpec(originalKey, "HmacSHA256")
    return NimbusJwtEncoder(ImmutableSecret(secretKeySpec))
}

@Bean
fun authenticationConverter(): JwtAuthenticationConverter {
    val grantedAuthoritiesConverter = JwtGrantedAuthoritiesConverter()
    grantedAuthoritiesConverter.setAuthorityPrefix("")

    val authenticationConverter = JwtAuthenticationConverter()
    authenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter)
    return authenticationConverter
}

//    @Bean
fun oAuth2AuthorizationRequestResolver(
    clientRegistrationRepository: ClientRegistrationRepository,
    oAuth2AuthorizationRequestCustomizer: OAuth2AuthorizationRequestCustomizer
): OAuth2AuthorizationRequestResolver {
    val resolver = DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI)
    resolver.setAuthorizationRequestCustomizer(oAuth2AuthorizationRequestCustomizer)
    return resolver
}

//    @Bean
fun oAuth2AccessTokenResponseClient(
    oAuth2AuthorizationCodeGrantRequestEntityConverter: OAuth2AuthorizationCodeGrantRequestEntityConverter,
    mapOAuth2AccessTokenResponseConverter: MapOAuth2AccessTokenResponseConverter
): OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
    val authorizationCodeTokenResponseClient = DefaultAuthorizationCodeTokenResponseClient()
    authorizationCodeTokenResponseClient.setRequestEntityConverter(oAuth2AuthorizationCodeGrantRequestEntityConverter)


    val tokenResponseHttpMessageConverter = OAuth2AccessTokenResponseHttpMessageConverter()
    tokenResponseHttpMessageConverter.supportedMediaTypes = listOf(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN)
    tokenResponseHttpMessageConverter.setAccessTokenResponseConverter(mapOAuth2AccessTokenResponseConverter)

    val restTemplate = RestTemplate(listOf(FormHttpMessageConverter(), tokenResponseHttpMessageConverter))
    authorizationCodeTokenResponseClient.setRestOperations(restTemplate)
    return authorizationCodeTokenResponseClient
}

内容的提问来源于stack exchange,提问作者BAHELAN

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.27 22:00:00