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); } }
项目结构

失效原因分析
你当前方案失效有两个核心问题:
- 登出逻辑顺序错误:你配置的
logoutSuccessUrl是token作废接口,但Spring执行登出时会先清空认证信息、销毁会话,进入作废接口时已经拿不到当前用户的access token,作废请求自然无效 - 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

