Support unbinding OAuth2User from Halo side (#6734)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.20.x

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

This PR provides an endpoint for disconnecting user connection. After the user connection is disconnected, an event `UserConnectionDisconnectedEvent` will be published for plugins.

Now, OAuth2 plugin can simplify the authentication, binding  and unbinding logic, please see the AuthProvider configuration snippet below:

```diff
spec:
  authenticationUrl: /oauth2/authorization/github
- bindingUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/connect/github
+ bindingUrl: /oauth2/authorization/github
- unbindUrl: /apis/api.plugin.halo.run/v1alpha1/plugins/plugin-oauth2/disconnect/github
+ unbindUrl: /apis/uc.api.auth.halo.run/v1alpha1/user-connections/github/disconnect
```

Please note that, OAuth2 plugin can also define binding and unbinding endpoints by self.

#### Special notes for your reviewer:

OAuth2 test plugin: 
[plugin-oauth2-1.0.4-SNAPSHOT.zip](https://github.com/user-attachments/files/17184215/plugin-oauth2-1.0.4-SNAPSHOT.zip)

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

```release-note
None
```
pull/6740/head
John Niang 2024-09-30 18:31:53 +08:00 committed by GitHub
parent c80ceb460d
commit 8a9b954969
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 135 additions and 1 deletions

View File

@ -0,0 +1,25 @@
package run.halo.app.event.user;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.plugin.SharedEvent;
/**
* An event that will be triggered after a user connection is disconnected.
*
* @author johnniang
* @since 2.20.0
*/
@SharedEvent
public class UserConnectionDisconnectedEvent extends ApplicationEvent {
@Getter
private final UserConnection userConnection;
public UserConnectionDisconnectedEvent(Object source, UserConnection userConnection) {
super(source);
this.userConnection = userConnection;
}
}

View File

@ -0,0 +1,74 @@
package run.halo.app.core.endpoint.uc;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import org.springdoc.core.fn.builders.parameter.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.extension.GroupVersion;
/**
* User connection endpoint.
*
* @author johnniang
* @since 2.20.0
*/
@Component
public class UserConnectionEndpoint implements CustomEndpoint {
private final UserConnectionService connectionService;
private final AuthenticationTrustResolver authenticationTrustResolver =
new AuthenticationTrustResolverImpl();
public UserConnectionEndpoint(UserConnectionService connectionService) {
this.connectionService = connectionService;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "UserConnectionV1alpha1Uc";
return SpringdocRouteBuilder.route()
.PUT(
"/user-connections/{registerId}/disconnect",
request -> {
var removedUserConnections = ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(authenticationTrustResolver::isAuthenticated)
.map(Authentication::getName)
.flatMapMany(username -> connectionService.removeUserConnection(
request.pathVariable("registerId"), username)
);
return ServerResponse.ok().body(removedUserConnections, UserConnection.class);
},
builder -> builder.operationId("DisconnectMyConnection")
.description("Disconnect my connection from a third-party platform.")
.tag(tag)
.parameter(Builder.parameterBuilder()
.in(ParameterIn.PATH)
.name("registerId")
.description("The registration ID of the third-party platform.")
.required(true)
.implementation(String.class)
)
.response(responseBuilder().implementationArray(UserConnection.class))
)
.build();
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("uc.api.auth.halo.run/v1alpha1");
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.core.user.service;
import org.springframework.security.oauth2.core.user.OAuth2User;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.UserConnection;
@ -32,4 +33,13 @@ public interface UserConnectionService {
String registrationId, OAuth2User oauth2User
);
/**
* Remove user connection.
*
* @param registrationId Registration ID
* @param username Username
* @return A list of user connections
*/
Flux<UserConnection> removeUserConnection(String registrationId, String username);
}

View File

@ -7,12 +7,15 @@ import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.time.Clock;
import java.util.HashMap;
import java.util.Optional;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.core.extension.UserConnection.UserConnectionSpec;
import run.halo.app.core.user.service.UserConnectionService;
import run.halo.app.event.user.UserConnectionDisconnectedEvent;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataOperator;
@ -25,10 +28,14 @@ public class UserConnectionServiceImpl implements UserConnectionService {
private final ReactiveExtensionClient client;
private final ApplicationEventPublisher eventPublisher;
private Clock clock = Clock.systemDefaultZone();
public UserConnectionServiceImpl(ReactiveExtensionClient client) {
public UserConnectionServiceImpl(ReactiveExtensionClient client,
ApplicationEventPublisher eventPublisher) {
this.client = client;
this.eventPublisher = eventPublisher;
}
void setClock(Clock clock) {
@ -91,6 +98,21 @@ public class UserConnectionServiceImpl implements UserConnectionService {
.flatMap(connection -> updateUserConnection(connection, oauth2User));
}
@Override
public Flux<UserConnection> removeUserConnection(String registrationId, String username) {
var listOptions = ListOptions.builder()
.fieldQuery(and(
equal("spec.registrationId", registrationId),
equal("spec.username", username)
))
.build();
return client.listAll(UserConnection.class, listOptions, defaultSort())
.flatMap(client::delete)
.doOnNext(deleted ->
eventPublisher.publishEvent(new UserConnectionDisconnectedEvent(this, deleted))
);
}
private void updateUserInfo(MetadataOperator metadata, OAuth2User oauth2User) {
var annotations = Optional.ofNullable(metadata.getAnnotations())
.orElseGet(HashMap::new);

View File

@ -29,6 +29,9 @@ rules:
resources: [ "plugins/bundle.js", "plugins/bundle.css" ]
resourceNames: [ "-" ]
verbs: [ "get" ]
- apisGroups: [ "uc.api.auth.halo.run" ]
resources: [ "user-connections/disconnect" ]
verbs: [ "update" ]
---
apiVersion: v1alpha1
kind: "Role"