From ac32fce08f48c5211291237cf25834f0f57065ba Mon Sep 17 00:00:00 2001 From: vsxd Date: Fri, 13 Mar 2026 11:57:33 +0800 Subject: [PATCH] fix(auth): preserve return target across oauth login --- .../skillhub/controller/AuthController.java | 37 +++++++++-- .../controller/AuthControllerTest.java | 34 ++++++++++- .../skillhub/auth/config/SecurityConfig.java | 5 ++ .../auth/oauth/OAuth2LoginFailureHandler.java | 22 +++++++ .../auth/oauth/OAuth2LoginSuccessHandler.java | 18 +++++- .../auth/oauth/OAuthLoginRedirectSupport.java | 24 ++++++++ ...HubOAuth2AuthorizationRequestResolver.java | 44 +++++++++++++ ...Auth2AuthorizationRequestResolverTest.java | 57 +++++++++++++++++ .../auth/oauth/OAuth2LoginHandlersTest.java | 61 +++++++++++++++++++ web/src/api/client.ts | 7 ++- web/src/features/auth/login-button.tsx | 10 ++- web/src/pages/login.tsx | 2 +- web/src/pages/register.tsx | 2 +- 13 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java index ec8dfca9..ae803aeb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -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> providers() { - var github = new AuthProviderResponse("github", "GitHub", "/oauth2/authorization/github"); - return ok("response.success.read", List.of(github)); + public ApiResponse> providers( + @RequestParam(name = "returnTo", required = false) String returnTo) { + String sanitizedReturnTo = com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo); + List 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); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 53d710bb..3946cbdc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -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" + ))); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index 660e4c3c..fbaceb91 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -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 mockAuthFilterProvider; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + SkillHubOAuth2AuthorizationRequestResolver authorizationRequestResolver, OAuth2LoginSuccessHandler successHandler, OAuth2LoginFailureHandler failureHandler, ApiTokenAuthenticationFilter apiTokenAuthenticationFilter, @@ -45,6 +48,7 @@ public class SecurityConfig { AccessDeniedHandler apiAccessDeniedHandler, ObjectProvider 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) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java index 71b90d01..4f18a23a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginFailureHandler.java @@ -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; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java index 083c9966..9328d905 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -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; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java new file mode 100644 index 00000000..6d1fd77f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthLoginRedirectSupport.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..6b42f593 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/SkillHubOAuth2AuthorizationRequestResolver.java @@ -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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java new file mode 100644 index 00000000..9e8a0a4d --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2AuthorizationRequestResolverTest.java @@ -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(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java new file mode 100644 index 00000000..e6e87bb8 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -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(); + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6bcedc04..4d328692 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -137,8 +137,11 @@ export async function getCurrentUser(): Promise { export const authApi = { getMe: getCurrentUser, - async getProviders(): Promise { - const providers = await unwrap(client.GET('/api/v1/auth/providers') as never) + async getProviders(returnTo?: string): Promise { + const params = returnTo + ? { query: { returnTo } } + : undefined + const providers = await unwrap(client.GET('/api/v1/auth/providers', params as never) as never) return providers .filter((provider) => provider.id && provider.name && provider.authorizationUrl) .map((provider) => ({ diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index 1ddb4812..a6f4c52e 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -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({ - queryKey: ['auth', 'providers'], - queryFn: authApi.getProviders, + queryKey: ['auth', 'providers', returnTo ?? ''], + queryFn: () => authApi.getProviders(returnTo), }) const providers = data ?? [] diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index f4fab058..2d766b1b 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -92,7 +92,7 @@ export function LoginPage() {

使用 GitHub 登录时,认证完成后会自动返回当前站点。

- + diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index c6e79f62..e56645e8 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -99,7 +99,7 @@ export function RegisterPage() {

直接使用现有 OAuth 账户进入平台,无需再创建本地密码。

- +