fix(auth): preserve return target across oauth login

This commit is contained in:
vsxd 2026-03-13 11:57:33 +08:00
parent 45e96be179
commit ac32fce08f
13 changed files with 309 additions and 14 deletions

View file

@ -5,20 +5,30 @@ import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.AuthMeResponse;
import com.iflytek.skillhub.dto.AuthProviderResponse;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.iflytek.skillhub.exception.UnauthorizedException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController extends BaseApiController {
public AuthController(ApiResponseFactory responseFactory) {
private final OAuth2ClientProperties oAuth2ClientProperties;
public AuthController(ApiResponseFactory responseFactory,
OAuth2ClientProperties oAuth2ClientProperties) {
super(responseFactory);
this.oAuth2ClientProperties = oAuth2ClientProperties;
}
@GetMapping("/me")
@ -31,8 +41,27 @@ public class AuthController extends BaseApiController {
}
@GetMapping("/providers")
public ApiResponse<List<AuthProviderResponse>> providers() {
var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github");
return ok("response.success.read", List.of(github));
public ApiResponse<List<AuthProviderResponse>> providers(
@RequestParam(name = "returnTo", required = false) String returnTo) {
String sanitizedReturnTo = com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
List<AuthProviderResponse> providers = new ArrayList<>(oAuth2ClientProperties.getRegistration().entrySet().stream()
.sorted(Comparator.comparing(entry -> entry.getKey()))
.map(entry -> new AuthProviderResponse(
entry.getKey(),
entry.getValue().getClientName() != null && !entry.getValue().getClientName().isBlank()
? entry.getValue().getClientName()
: entry.getKey(),
buildAuthorizationUrl(entry.getKey(), sanitizedReturnTo)
))
.toList());
return ok("response.success.read", providers);
}
private String buildAuthorizationUrl(String registrationId, String returnTo) {
String baseUrl = "/oauth2/authorization/" + registrationId;
if (returnTo == null) {
return baseUrl;
}
return baseUrl + "?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8);
}
}

View file

@ -10,11 +10,13 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.hamcrest.Matchers.hasItems;
import static org.mockito.BDDMockito.given;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -25,6 +27,20 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@TestPropertySource(properties = {
"spring.security.oauth2.client.registration.github.client-name=GitHub",
"spring.security.oauth2.client.registration.gitee.client-id=placeholder",
"spring.security.oauth2.client.registration.gitee.client-secret=placeholder",
"spring.security.oauth2.client.registration.gitee.provider=gitee",
"spring.security.oauth2.client.registration.gitee.authorization-grant-type=authorization_code",
"spring.security.oauth2.client.registration.gitee.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
"spring.security.oauth2.client.registration.gitee.scope=user_info",
"spring.security.oauth2.client.registration.gitee.client-name=Gitee",
"spring.security.oauth2.client.provider.gitee.authorization-uri=https://gitee.com/oauth/authorize",
"spring.security.oauth2.client.provider.gitee.token-uri=https://gitee.com/oauth/token",
"spring.security.oauth2.client.provider.gitee.user-info-uri=https://gitee.com/api/v5/user",
"spring.security.oauth2.client.provider.gitee.user-name-attribute=id"
})
class AuthControllerTest {
@Autowired
@ -79,9 +95,23 @@ class AuthControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").isNotEmpty())
.andExpect(jsonPath("$.data[0].id").value("github"))
.andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github"))
.andExpect(jsonPath("$.data.length()").value(2))
.andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee")))
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github",
"/oauth2/authorization/gitee"
)))
.andExpect(jsonPath("$.timestamp").isNotEmpty())
.andExpect(jsonPath("$.requestId").isNotEmpty());
}
@Test
void providersShouldAppendReturnToWhenRequested() throws Exception {
mockMvc.perform(get("/api/v1/auth/providers").param("returnTo", "/dashboard/publish"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[*].authorizationUrl", hasItems(
"/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish",
"/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish"
)));
}
}

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.config;
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler;
import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolver;
import com.iflytek.skillhub.auth.mock.MockAuthFilter;
import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter;
import org.springframework.beans.factory.ObjectProvider;
@ -30,6 +31,7 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
@ -38,6 +40,7 @@ public class SecurityConfig {
private final ObjectProvider<MockAuthFilter> mockAuthFilterProvider;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter,
@ -45,6 +48,7 @@ public class SecurityConfig {
AccessDeniedHandler apiAccessDeniedHandler,
ObjectProvider<MockAuthFilter> mockAuthFilterProvider) {
this.customOAuth2UserService = customOAuth2UserService;
this.authorizationRequestResolver = authorizationRequestResolver;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
@ -101,6 +105,7 @@ public class SecurityConfig {
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(endpoint -> endpoint.authorizationRequestResolver(authorizationRequestResolver))
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(successHandler)
.failureHandler(failureHandler)

View file

@ -3,11 +3,14 @@ package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@ -16,6 +19,7 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
String returnTo = consumeReturnTo(request.getSession(false));
if (exception instanceof AccountPendingException) {
getRedirectStrategy().sendRedirect(request, response, "/pending-approval");
return;
@ -30,6 +34,24 @@ public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHan
return;
}
if (returnTo != null) {
getRedirectStrategy().sendRedirect(
request,
response,
"/login?returnTo=" + URLEncoder.encode(returnTo, StandardCharsets.UTF_8)
);
return;
}
super.onAuthenticationFailure(request, response, exception);
}
private String consumeReturnTo(HttpSession session) {
if (session == null) {
return null;
}
Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null;
}
}

View file

@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
@ -15,7 +16,7 @@ import java.io.IOException;
public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
public OAuth2LoginSuccessHandler() {
setDefaultTargetUrl("/");
setDefaultTargetUrl(OAuthLoginRedirectSupport.DEFAULT_TARGET_URL);
}
@Override
@ -27,6 +28,21 @@ public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSu
request.getSession().setAttribute("platformPrincipal", principal);
}
}
String returnTo = consumeReturnTo(request.getSession(false));
if (returnTo != null) {
getRedirectStrategy().sendRedirect(request, response, returnTo);
clearAuthenticationAttributes(request);
return;
}
super.onAuthenticationSuccess(request, response, authentication);
}
private String consumeReturnTo(HttpSession session) {
if (session == null) {
return null;
}
Object value = session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
return value instanceof String str ? OAuthLoginRedirectSupport.sanitizeReturnTo(str) : null;
}
}

View file

@ -0,0 +1,24 @@
package com.iflytek.skillhub.auth.oauth;
public final class OAuthLoginRedirectSupport {
public static final String SESSION_RETURN_TO_ATTRIBUTE = "skillhub.oauth.returnTo";
public static final String DEFAULT_TARGET_URL = "/dashboard";
private OAuthLoginRedirectSupport() {
}
public static String sanitizeReturnTo(String candidate) {
if (candidate == null || candidate.isBlank()) {
return null;
}
String trimmed = candidate.trim();
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
return null;
}
if (trimmed.contains("\r") || trimmed.contains("\n")) {
return null;
}
return trimmed;
}
}

View file

@ -0,0 +1,44 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
@Component
public class SkillHubOAuth2AuthorizationRequestResolver
implements org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver {
private final DefaultOAuth2AuthorizationRequestResolver delegate;
public SkillHubOAuth2AuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
this.delegate = new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository,
"/oauth2/authorization"
);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request);
rememberReturnTo(request);
return authorizationRequest;
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request, clientRegistrationId);
rememberReturnTo(request);
return authorizationRequest;
}
private void rememberReturnTo(HttpServletRequest request) {
String returnTo = OAuthLoginRedirectSupport.sanitizeReturnTo(request.getParameter("returnTo"));
if (returnTo == null) {
request.getSession().removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
return;
}
request.getSession().setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, returnTo);
}
}

View file

@ -0,0 +1,57 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import static org.assertj.core.api.Assertions.assertThat;
class OAuth2AuthorizationRequestResolverTest {
private SkillHubOAuth2AuthorizationRequestResolver resolver;
@BeforeEach
void setUp() {
ClientRegistration github = ClientRegistration.withRegistrationId("github")
.clientId("client")
.clientSecret("secret")
.authorizationUri("https://example.test/oauth/authorize")
.tokenUri("https://example.test/oauth/token")
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.userInfoUri("https://example.test/user")
.userNameAttributeName("id")
.authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE)
.scope("read:user")
.clientName("GitHub")
.build();
resolver = new SkillHubOAuth2AuthorizationRequestResolver(new InMemoryClientRegistrationRepository(github));
}
@Test
void resolve_storesSanitizedReturnToInSession() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github");
request.setParameter("returnTo", "/dashboard/publish?draft=1");
resolver.resolve(request, "github");
HttpSession session = request.getSession(false);
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE))
.isEqualTo("/dashboard/publish?draft=1");
}
@Test
void resolve_ignoresUnsafeReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/oauth2/authorization/github");
request.setParameter("returnTo", "https://evil.example");
resolver.resolve(request, "github");
HttpSession session = request.getSession(false);
assertThat(session).isNotNull();
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
}

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class OAuth2LoginHandlersTest {
@Test
void successHandler_redirectsToStoredReturnTo() throws Exception {
OAuth2LoginSuccessHandler handler = new OAuth2LoginSuccessHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpSession session = request.getSession(true);
session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/dashboard/publish");
var principal = new com.iflytek.skillhub.auth.rbac.PlatformPrincipal(
"user-1", "User", "user@example.com", null, "github", Set.of()
);
Authentication authentication = new UsernamePasswordAuthenticationToken(
new DefaultOAuth2User(List.of(), Map.of("platformPrincipal", principal, "login", "user"), "login"),
null,
List.of()
);
handler.onAuthenticationSuccess(request, response, authentication);
assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish");
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
void failureHandler_redirectsBackToLoginWithReturnTo() throws Exception {
OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
HttpSession session = request.getSession(true);
session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, "/settings/accounts");
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(new OAuth2Error("invalid_request"))
);
assertThat(response.getRedirectedUrl()).isEqualTo("/login?returnTo=%2Fsettings%2Faccounts");
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
}
}

View file

@ -137,8 +137,11 @@ export async function getCurrentUser(): Promise<User | null> {
export const authApi = {
getMe: getCurrentUser,
async getProviders(): Promise<OAuthProvider[]> {
const providers = await unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers') as never)
async getProviders(returnTo?: string): Promise<OAuthProvider[]> {
const params = returnTo
? { query: { returnTo } }
: undefined
const providers = await unwrap<OAuthProvider[]>(client.GET('/api/v1/auth/providers', params as never) as never)
return providers
.filter((provider) => provider.id && provider.name && provider.authorizationUrl)
.map((provider) => ({

View file

@ -3,10 +3,14 @@ import { authApi } from '@/api/client'
import { Button } from '@/shared/ui/button'
import type { OAuthProvider } from '@/api/types'
export function LoginButton() {
interface LoginButtonProps {
returnTo?: string
}
export function LoginButton({ returnTo }: LoginButtonProps) {
const { data, isLoading } = useQuery<OAuthProvider[]>({
queryKey: ['auth', 'providers'],
queryFn: authApi.getProviders,
queryKey: ['auth', 'providers', returnTo ?? ''],
queryFn: () => authApi.getProviders(returnTo),
})
const providers = data ?? []

View file

@ -92,7 +92,7 @@ export function LoginPage() {
<p className="text-sm text-muted-foreground">
使 GitHub
</p>
<LoginButton />
<LoginButton returnTo={returnTo} />
</TabsContent>
</Tabs>
</div>

View file

@ -99,7 +99,7 @@ export function RegisterPage() {
<p className="text-sm text-muted-foreground">
使 OAuth
</p>
<LoginButton />
<LoginButton returnTo={returnTo} />
</TabsContent>
</Tabs>
</CardContent>