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 notFavicon = new NegatedServerWebExchangeMatcher(
pathMatchers(
"/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**"
"/favicon.*", "/login/**", "/signup/**", "/password-reset/**", "/challenges/**",
"/oauth2/**", "/social/**"
));
var html = new MediaTypeServerWebExchangeMatcher(MediaType.TEXT_HTML);
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 reactor.core.publisher.Mono;
import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.security.LoginHandlerEnhancer;
/**
* A filter to map OAuth2 authentication to authenticated user.
@ -47,6 +48,8 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
private final ServerLogoutHandler logoutHandler;
private final LoginHandlerEnhancer loginHandlerEnhancer;
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
@Setter
@ -56,10 +59,12 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
public MapOAuth2AuthenticationFilter(
ServerSecurityContextRepository securityContextRepository,
UserConnectionService connectionService,
ReactiveUserDetailsService userDetailsService) {
ReactiveUserDetailsService userDetailsService,
LoginHandlerEnhancer loginHandlerEnhancer) {
this.connectionService = connectionService;
this.securityContextRepository = securityContextRepository;
this.userDetailsService = userDetailsService;
this.loginHandlerEnhancer = loginHandlerEnhancer;
var logoutHandler = new SecurityContextServerLogoutHandler();
logoutHandler.setSecurityContextRepository(securityContextRepository);
this.logoutHandler = logoutHandler;
@ -116,7 +121,10 @@ class MapOAuth2AuthenticationFilter implements WebFilter {
.map(userDetails -> authenticated(userDetails, oauth2Token))
.flatMap(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
// 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.stereotype.Component;
import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.security.LoginHandlerEnhancer;
import run.halo.app.security.authentication.SecurityConfigurer;
/**
@ -25,17 +26,21 @@ class OAuth2SecurityConfigurer implements SecurityConfigurer {
private final ReactiveUserDetailsService userDetailsService;
private final LoginHandlerEnhancer loginHandlerEnhancer;
public OAuth2SecurityConfigurer(ServerSecurityContextRepository securityContextRepository,
UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService) {
UserConnectionService connectionService, ReactiveUserDetailsService userDetailsService,
LoginHandlerEnhancer loginHandlerEnhancer) {
this.securityContextRepository = securityContextRepository;
this.connectionService = connectionService;
this.userDetailsService = userDetailsService;
this.loginHandlerEnhancer = loginHandlerEnhancer;
}
@Override
public void configure(ServerHttpSecurity http) {
var mapOAuth2Filter = new MapOAuth2AuthenticationFilter(
securityContextRepository, connectionService, userDetailsService
securityContextRepository, connectionService, userDetailsService, loginHandlerEnhancer
);
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 java.net.URI;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
@ -10,6 +11,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.server.savedrequest.ServerRequestCache;
import org.springframework.stereotype.Component;
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())
.build());
}

View File

@ -293,7 +293,15 @@
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);
font-size: var(--text-sm);
color: #1f2937;
@ -313,7 +321,7 @@
background: #f3f4f6;
}
.pill-items li:hover a {
.pill-items li:hover {
color: #111827;
}

View File

@ -113,13 +113,35 @@
</div>
<ul class="pill-items">
<li th:each="provider : ${socialAuthProviders}">
<a th:href="${provider.spec.authenticationUrl}">
<form
class="social-auth-provider-form"
th:action="|/login/social/${provider.metadata.name}?remember-me=false|"
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>
</a>
</button>
</form>
</li>
</ul>
</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 th:remove="tag" th:fragment="passwordResetMethods">