Add social login endpoint for remember-me support (#7670)

#### What type of PR is this?

/kind feature
/kind api-change
/area core
/milestone 2.21.x

#### What this PR does / why we need it:

This PR adds a new endpoint `POST /login/social/{auth_provider_name}?remember-me=true` to make the social login support remember-me mechanism. 

#### Does this PR introduce a user-facing change?

```release-note
支持社交登录时选择是否保持登录
```
pull/7677/head
John Niang 2025-08-08 19:06:39 +08:00 committed by GitHub
parent 9607ee4912
commit 576dda9d74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 75 additions and 11 deletions

View File

@ -106,7 +106,8 @@ public class HaloServerRequestCache extends WebSessionServerRequestCache {
var get = pathMatchers(HttpMethod.GET, "/**"); var get = pathMatchers(HttpMethod.GET, "/**");
var notFavicon = new NegatedServerWebExchangeMatcher( var notFavicon = new NegatedServerWebExchangeMatcher(
pathMatchers( pathMatchers(
"/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**" "/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**",
"/oauth2/**", "/social/**"
)); ));
var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML); var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML);
html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); html.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL));

View File

@ -23,6 +23,7 @@ import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.security.LoginHandlerEnhancer;
/** /**
* A filter to map OAuth2 authentication to authenticated user. * A filter to map OAuth2 authentication to authenticated user.
@ -47,6 +48,8 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
private final ServerLogoutHandler logoutHandler; private final ServerLogoutHandler logoutHandler;
private final LoginHandlerEnhancer loginHandlerEnhancer;
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
@Setter @Setter
@ -56,10 +59,12 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
public MapOAuth2AuthenticationFilter( public MapOAuth2AuthenticationFilter(
ServerSecurityContextRepository securityContextRepository, ServerSecurityContextRepository securityContextRepository,
UserConnectionService connectionService, UserConnectionService connectionService,
ReactiveUserDetailsService userDetailsService) { ReactiveUserDetailsService userDetailsService,
LoginHandlerEnhancer loginHandlerEnhancer) {
this.connectionService = connectionService; this.connectionService = connectionService;
this.securityContextRepository = securityContextRepository; this.securityContextRepository = securityContextRepository;
this.userDetailsService = userDetailsService; this.userDetailsService = userDetailsService;
this.loginHandlerEnhancer = loginHandlerEnhancer;
var logoutHandler = new SecurityContextServerLogoutHandler(); var logoutHandler = new SecurityContextServerLogoutHandler();
logoutHandler.setSecurityContextRepository(securityContextRepository); logoutHandler.setSecurityContextRepository(securityContextRepository);
this.logoutHandler = logoutHandler; this.logoutHandler = logoutHandler;
@ -116,7 +121,10 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
.map(userDetails -> authenticated(userDetails, oauth2Token)) .map(userDetails -> authenticated(userDetails, oauth2Token))
.flatMap(haloOAuth2Token -> { .flatMap(haloOAuth2Token -> {
var securityContext = new SecurityContextImpl(haloOAuth2Token); var securityContext = new SecurityContextImpl(haloOAuth2Token);
return securityContextRepository.save(exchange, securityContext); return securityContextRepository.save(exchange, securityContext)
.then(
loginHandlerEnhancer.onLoginSuccess(exchange, haloOAuth2Token)
);
// because this happens after the filter, there is no need to // because this happens after the filter, there is no need to
// write SecurityContext to the context // write SecurityContext to the context
}); });

View File

@ -7,6 +7,7 @@ import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.web.server.context.ServerSecurityContextRepository; import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import run.halo.app.core.user.service.UserConnectionService; import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.security.LoginHandlerEnhancer;
import run.halo.app.security.authentication.SecurityConfigurer; import run.halo.app.security.authentication.SecurityConfigurer;
/** /**
@ -25,17 +26,21 @@ class OAuth2SecurityConfigurer implements SecurityConfigurer {
private final ReactiveUserDetailsService userDetailsService; private final ReactiveUserDetailsService userDetailsService;
private final LoginHandlerEnhancer loginHandlerEnhancer;
public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository, public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository,
UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService) { UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService,
LoginHandlerEnhancer loginHandlerEnhancer) {
this.securityContextRepository = securityContextRepository; this.securityContextRepository = securityContextRepository;
this.connectionService = connectionService; this.connectionService = connectionService;
this.userDetailsService = userDetailsService; this.userDetailsService = userDetailsService;
this.loginHandlerEnhancer = loginHandlerEnhancer;
} }
@Override @Override
public void configure(ServerHttpSecurity http) { public void configure(ServerHttpSecurity http) {
var mapOAuth2Filter = new MapOAuth2AuthenticationFilter( var mapOAuth2Filter = new MapOAuth2AuthenticationFilter(
securityContextRepository, connectionService, userDetailsService securityContextRepository, connectionService, userDetailsService, loginHandlerEnhancer
); );
http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION); http.addFilterBefore(mapOAuth2Filter, SecurityWebFiltersOrder.AUTHENTICATION);
} }

View File

@ -2,6 +2,7 @@ package run.halo.app.security.preauth;
import static org.springframework.web.reactive.function.server.RequestPredicates.path; import static org.springframework.web.reactive.function.server.RequestPredicates.path;
import java.net.URI;
import java.util.Base64; import java.util.Base64;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@ -10,6 +11,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.server.savedrequest.ServerRequestCache; import org.springframework.security.web.server.savedrequest.ServerRequestCache;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
@ -115,6 +117,24 @@ class PreAuthLoginEndpoint {
)) ))
)); ));
}) })
.POST("/social/{authProviderName}", request -> {
var authProviderName = request.pathVariable("authProviderName");
return authProviderService.getEnabledProviders()
.filter(ap -> Objects.equals(authProviderName, ap.getMetadata().getName()))
.filter(ap -> !AuthProvider.AuthType.FORM.equals(ap.getSpec().getAuthType()))
.next()
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
"Auth provider " + authProviderName + " not found or not enabled."
)))
.flatMap(ap -> {
var authenticationUrl = ap.getSpec().getAuthenticationUrl();
return rememberMeRequestCache.saveRememberMe(request.exchange())
.then(Mono.defer(() -> ServerResponse.status(HttpStatus.FOUND)
.location(URI.create(authenticationUrl))
.build()
));
});
})
.before(HaloUtils.noCache()) .before(HaloUtils.noCache())
.build()); .build());
} }

View File

@ -293,7 +293,15 @@
overflow: hidden; overflow: hidden;
} }
.pill-items li a { .pill-items li button {
all: unset;
cursor: pointer;
width: 100%;
height: 100%;
}
.pill-items li a,
.pill-items li button {
gap: var(--spacing-sm); gap: var(--spacing-sm);
font-size: var(--text-sm); font-size: var(--text-sm);
color: #1f2937; color: #1f2937;
@ -313,7 +321,7 @@
background: #f3f4f6; background: #f3f4f6;
} }
.pill-items li:hover a { .pill-items li:hover {
color: #111827; color: #111827;
} }

View File

@ -113,13 +113,35 @@
</div> </div>
<ul class="pill-items"> <ul class="pill-items">
<li th:each="provider : ${socialAuthProviders}"> <li th:each="provider : ${socialAuthProviders}">
<a th:href="${provider.spec.authenticationUrl}"> <form
<img th:src="${provider.spec.logo}" th:alt="|${provider.spec.displayName}'s icon|" /> class="social-auth-provider-form"
<span th:text="${provider.spec.displayName}"></span> th:action="|/login/social/${provider.metadata.name}?remember-me=false|"
</a> method="post"
>
<button type="submit">
<img th:src="${provider.spec.logo}" th:alt="|${provider.spec.displayName}'s icon|" />
<span th:text="${provider.spec.displayName}"></span>
</button>
</form>
</li> </li>
</ul> </ul>
</th:block> </th:block>
<script>
document.addEventListener("DOMContentLoaded", function () {
const rememberMeCheckbox = document.getElementById("remember-me");
const socialAuthProviders = document.querySelectorAll(".social-auth-provider-form");
rememberMeCheckbox.addEventListener("change", function (e) {
const rememberMe = e.target.checked;
socialAuthProviders.forEach((form) => {
const url = new URL(form.action, window.location.origin);
url.searchParams.set("remember-me", rememberMe ? "true" : "false");
form.action = url.pathname + url.search;
});
});
});
</script>
</div> </div>
<div th:remove="tag" th:fragment="passwordResetMethods"> <div th:remove="tag" th:fragment="passwordResetMethods">