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

Spring Security集成Twitch OAuth如何实现登出后强制重输登录凭证

问题背景

我有一个使用Twitch提供的OAuth做安全验证的Spring应用,想要实现用户点击登出按钮后,再次登录站点时必须重新输入Twitch凭证的效果。根据我查阅的资料,OAuth的单点登出实现难度较高,不过Twitch API官方提供了作废OAuth token的能力。
我了解到OIDC中有prompt=login参数,但我没找到相关使用方法,同时我担心该参数会导致用户每次登录都需要重新输入凭证,而不是仅在登出后要求输入。
我最初参考了相关方案但没有生效,访问受限接口时仍不需要输入凭证,Spring会直接和Twitch完成快速重认证。
我当前的方案是直接发送POST请求调用Twitch的作废接口,但同样没有生效,相关代码如下:
我更希望使用Spring原生功能解决该问题。另外我发现访问/logout接口时,浏览器会重定向到Twitch的授权端点,我猜测Spring要么是尝试向授权端点发送token作废请求,要么是在登出后立刻自动重新登录,该现象供参考。
另外我忘记提及的点:删除浏览器所有Cookie后就能正常登出,是否可以在用户登出时模拟该效果(当然不需要删除用户的其他Cookie)?

相关代码

SpringSecurityConfiguration

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        //This allows users to access the "/" and "/Info" endpoints without authenticating with Twitch. To go anywhere else they will have to authenticate.
        httpSecurity.antMatcher("/**").authorizeRequests().antMatchers("/", "/Info", "/token/deletion").permitAll().anyRequest().authenticated().and().oauth2Login().and()
                //This configures logout tells spring to do the logout with the method in the logoutSuccessHandler
                .logout().logoutSuccessUrl("http://localhost:8080/token/deletion").invalidateHttpSession(true).clearAuthentication(true).deleteCookies("JSESSIONID", "JWT");
}

application.properties

spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp

spring.security.oauth2.client.registration.twitch=twitch
spring.security.oauth2.client.registration.twitch.client-id=redacted
spring.security.oauth2.client.registration.twitch.client-secret=redacted
spring.security.oauth2.client.registration.twitch.client-authentication-method=post
spring.security.oauth2.client.registration.twitch.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.twitch.redirect-uri=http://localhost:8080/login/oauth2/code/twitch
spring.security.oauth2.client.registration.twitch.scope=user:read:email
spring.security.oauth2.client.registration.twitch.client-name=Twitch

spring.security.oauth2.client.provider.twitch.authorization-uri=https://id.twitch.tv/oauth2/authorize
spring.security.oauth2.client.provider.twitch.token-uri=https://id.twitch.tv/oauth2/token
spring.security.oauth2.client.provider.twitch.jwk-set-uri=https://id.twitch.tv/oauth2/keys
spring.security.oauth2.client.provider.twitch.user-info-uri=https://id.twitch.tv/oauth2/userinfo
spring.security.oauth2.client.provider.twitch.user-info-authentication-method=post
spring.security.oauth2.client.provider.twitch.user-name-attribute=sub

TokenRemovalController

import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.net.ssl.HttpsURLConnection;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;


@Controller
public class TokenRemovalController {

    @RequestMapping("/token/deletion")
    public void removeTokenFromTwitch(HttpServletResponse response, @RegisteredOAuth2AuthorizedClient("twitch") OAuth2AuthorizedClient authorizedClient) throws IOException {
        //Get access token of current user
        String accessToken = authorizedClient.getAccessToken().getTokenValue();

        //POST Request to Twitch endpoint
        URL url = new URL("https://id.twitch.tv/oauth2/revoke");
        HttpsURLConnection https = (HttpsURLConnection)url.openConnection();
        https.setRequestMethod("POST");
        https.setDoOutput(true);
        https.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");

        String data = "client_id=redacted&token=" + accessToken;

        byte[] out = data.getBytes(StandardCharsets.UTF_8);

        OutputStream stream = https.getOutputStream();
        stream.write(out);

        String redirectString = "/error";
        if (https.getResponseCode() == 200) {
            redirectString = "/Info";
        }

        https.disconnect();

        response.sendRedirect(redirectString);
    }
}

项目结构

项目结构示意图

解决方案

失效原因分析

你当前方案失效有两个核心问题:

  1. 登出逻辑顺序错误:你配置的logoutSuccessUrl是token作废接口,但Spring执行登出时会先清空认证信息、销毁会话,进入作废接口时已经拿不到当前用户的access token,作废请求自然无效
  2. Twitch侧SSO会话残留:就算你成功作废了当前应用的access token,用户浏览器在Twitch域名下还有登录态Cookie,下次跳转授权时Twitch会直接跳过登录步骤返回授权码,导致无感知重登录

实现步骤

完全基于Spring Security原生扩展点实现,不需要额外依赖,也不会影响正常场景的登录体验:

第一步:调整登出逻辑,先作废token再清理本地会话

修改SpringSecurityConfiguration的登出配置,用LogoutHandler在Spring清理会话前执行token作废,同时标记用户需要重新登录:

@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.antMatcher("/**")
            .authorizeRequests()
            .antMatchers("/", "/Info").permitAll()
            .anyRequest().authenticated()
            .and()
            .oauth2Login()
            // 绑定自定义授权请求解析器,用于动态加prompt参数
            .authorizationEndpoint()
            .authorizationRequestResolver(new CustomAuthorizationRequestResolver(clientRegistrationRepository))
            .and()
            .and()
            .logout()
            .addLogoutHandler((request, response, authentication) -> {
                if (authentication != null) {
                    // 从会话中拿还没被销毁的授权客户端信息
                    OAuth2AuthorizedClient client = (OAuth2AuthorizedClient) request.getSession()
                            .getAttribute("OAUTH2_AUTHORIZED_CLIENT_twitch");
                    if (client != null) {
                        String accessToken = client.getAccessToken().getTokenValue();
                        // 调用Twitch作废接口,逻辑和你之前的实现一致
                        try {
                            URL url = new URL("https://id.twitch.tv/oauth2/revoke");
                            HttpsURLConnection https = (HttpsURLConnection)url.openConnection();
                            https.setRequestMethod("POST");
                            https.setDoOutput(true);
                            https.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
                            String data = "client_id=替换为你的Twitch clientId&token=" + accessToken;
                            byte[] out = data.getBytes(StandardCharsets.UTF_8);
                            OutputStream stream = https.getOutputStream();
                            stream.write(out);
                            https.getResponseCode();
                            https.disconnect();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
                // 标记下次登录需要强制输入凭证
                request.getSession().setAttribute("NEED_RELOGIN", true);
            })
            .logoutSuccessUrl("/Info")
            .invalidateHttpSession(true)
            .clearAuthentication(true)
            .deleteCookies("JSESSIONID", "JWT");
}

第二步:实现自定义授权请求解析器,动态携带prompt参数

只有用户登出后首次登录才会加prompt=login参数,不会影响正常使用时的自动登录:

public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
    private final OAuth2AuthorizationRequestResolver defaultResolver;

    public CustomAuthorizationRequestResolver(ClientRegistrationRepository repo) {
        this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, 
                OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
    }

    @Override
    public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
        OAuth2AuthorizationRequest req = defaultResolver.resolve(request);
        return customizeAuthorizationRequest(req, request);
    }

    @Override
    public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
        OAuth2AuthorizationRequest req = defaultResolver.resolve(request, clientRegistrationId);
        return customizeAuthorizationRequest(req, request);
    }

    private OAuth2AuthorizationRequest customizeAuthorizationRequest(OAuth2AuthorizationRequest req, HttpServletRequest request) {
        if (req == null) return null;
        HttpSession session = request.getSession(false);
        if (session != null && Boolean.TRUE.equals(session.getAttribute("NEED_RELOGIN"))) {
            session.removeAttribute("NEED_RELOGIN");
            Map<String, Object> additionalParams = new HashMap<>(req.getAdditionalParameters());
            additionalParams.put("prompt", "login");
            return OAuth2AuthorizationRequest.from(req).additionalParameters(additionalParams).build();
        }
        return req;
    }
}

第三步:删除原来的TokenRemovalController即可,不需要单独的token作废接口

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.24 07:54:06