Skip to content

Commit

Permalink
Support binding OAuth2 user automatically (#6702)
Browse files Browse the repository at this point in the history
#### What type of PR is this?

/kind improvement
/area core
/milestone 2.20.x

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

This PR add support for binding OAuth2 user automatically. So we can remove the user-binding page.

Please note that those changes may break the OAuth2 and SocialLogin plugins.

#### Special notes for your reviewer:

Build OAuth2 plugin from <halo-sigs/plugin-oauth2#64> or use [plugin-oauth2-1.0.4-SNAPSHOT.zip](https://github.com/user-attachments/files/17177592/plugin-oauth2-1.0.4-SNAPSHOT.zip) I built.

- Bind after logging in

1. Log in Halo with username and password method
2. Try to unbind OAuth2 user
3. Bind OAuth2 user again

- Initially bind without logging in

1. Go to login page
2. Log in with OAuth2 method and you will be redirected to login page
3. Log in with username and password method
4. See the result of binding

- Log in with OAuth2 method after binding

1. Go  to login page
2. Log in with OAuth2 method and you will be redirected to uc page directly

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

```release-note
支持自动绑定 OAuth2 登录用户
```
  • Loading branch information
JohnNiang authored Sep 29, 2024
1 parent 83109d0 commit e779543
Show file tree
Hide file tree
Showing 39 changed files with 936 additions and 136 deletions.
31 changes: 2 additions & 29 deletions api/src/main/java/run/halo/app/core/extension/UserConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,36 +48,9 @@ public static class UserConnectionSpec {
private String providerUserId;

/**
* The display name for the user's connection to the OAuth provider.
* The time when the user connection was last updated.
*/
@Schema(requiredMode = REQUIRED)
private String displayName;

/**
* The URL to the user's profile page on the OAuth provider.
* For example, the user's GitHub profile URL.
*/
private String profileUrl;

/**
* The URL to the user's avatar image on the OAuth provider.
* For example, the user's GitHub avatar URL.
*/
private String avatarUrl;

/**
* The access token provided by the OAuth provider.
*/
@Schema(requiredMode = REQUIRED)
private String accessToken;

/**
* The refresh token provided by the OAuth provider (if applicable).
*/
private String refreshToken;

private Instant expiresAt;

private Instant updatedAt;

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package run.halo.app.security;

import org.pf4j.ExtensionPoint;
import org.springframework.web.server.WebFilter;

/**
* Security web filter for HTTP basic.
*
* @author johnniang
* @since 2.20.0
*/
public interface HttpBasicSecurityWebFilter extends WebFilter, ExtensionPoint {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package run.halo.app.security;

import org.pf4j.ExtensionPoint;
import org.springframework.web.server.WebFilter;

/**
* Security web filter for OAuth2 authorization code.
*
* @author johnniang
* @since 2.20.0
*/
public interface OAuth2AuthorizationCodeSecurityWebFilter extends WebFilter, ExtensionPoint {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package run.halo.app.security.authentication.oauth2;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import lombok.Getter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;

/**
* Halo OAuth2 authentication token which combines {@link UserDetails} and original
* {@link OAuth2AuthenticationToken}.
*
* @author johnniang
* @since 2.20.0
*/
public class HaloOAuth2AuthenticationToken extends AbstractAuthenticationToken {

@Getter
private final UserDetails userDetails;

@Getter
private final OAuth2AuthenticationToken original;

/**
* Constructs an {@code HaloOAuth2AuthenticationToken} using {@link UserDetails} and original
* {@link OAuth2AuthenticationToken}.
*
* @param userDetails the {@link UserDetails}
* @param original the original {@link OAuth2AuthenticationToken}
*/
public HaloOAuth2AuthenticationToken(UserDetails userDetails,
OAuth2AuthenticationToken original) {
super(combineAuthorities(userDetails, original));
this.userDetails = userDetails;
this.original = original;
setAuthenticated(true);
}

@Override
public String getName() {
return userDetails.getUsername();
}

@Override
public Collection<GrantedAuthority> getAuthorities() {
var originalAuthorities = super.getAuthorities();
var userDetailsAuthorities = getUserDetails().getAuthorities();
var authorities = new ArrayList<GrantedAuthority>(
originalAuthorities.size() + userDetailsAuthorities.size()
);
authorities.addAll(originalAuthorities);
authorities.addAll(userDetailsAuthorities);
return Collections.unmodifiableList(authorities);
}

@Override
public Object getCredentials() {
return "";
}

@Override
public OAuth2User getPrincipal() {
return original.getPrincipal();
}

/**
* Creates an authenticated {@link HaloOAuth2AuthenticationToken} using {@link UserDetails} and
* original {@link OAuth2AuthenticationToken}.
*
* @param userDetails the {@link UserDetails}
* @param original the original {@link OAuth2AuthenticationToken}
* @return an authenticated {@link HaloOAuth2AuthenticationToken}
*/
public static HaloOAuth2AuthenticationToken authenticated(
UserDetails userDetails, OAuth2AuthenticationToken original
) {
return new HaloOAuth2AuthenticationToken(userDetails, original);
}

private static Collection<? extends GrantedAuthority> combineAuthorities(
UserDetails userDetails, OAuth2AuthenticationToken original) {
var userDetailsAuthorities = userDetails.getAuthorities();
var originalAuthorities = original.getAuthorities();
var authorities = new ArrayList<GrantedAuthority>(
originalAuthorities.size() + userDetailsAuthorities.size()
);
authorities.addAll(originalAuthorities);
authorities.addAll(userDetailsAuthorities);
return Collections.unmodifiableList(authorities);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;

@Component
@RequiredArgsConstructor
Expand Down Expand Up @@ -600,7 +599,7 @@ record ChangePasswordRequest(
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(auth -> !(auth instanceof TwoFactorAuthentication))
.filter(Authentication::isAuthenticated)
.flatMap(auth -> userService.getUser(auth.getName())
.flatMap(user -> {
var roleNames = authoritiesToRoles(auth.getAuthorities());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package run.halo.app.core.user.service;

import org.springframework.security.oauth2.core.user.OAuth2User;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.UserConnection;

public interface UserConnectionService {

/**
* Create user connection.
*
* @param username Username
* @param registrationId Registration id
* @param oauth2User OAuth2 user
* @return Created user connection
*/
Mono<UserConnection> createUserConnection(
String username,
String registrationId,
OAuth2User oauth2User
);

/**
* Update the user connection if present.
* If found, update updatedAt timestamp of the user connection.
*
* @param registrationId Registration id
* @param oauth2User OAuth2 user
* @return Updated user connection or empty
*/
Mono<UserConnection> updateUserConnectionIfPresent(
String registrationId, OAuth2User oauth2User
);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package run.halo.app.core.user.service.impl;

import static run.halo.app.extension.ExtensionUtil.defaultSort;
import static run.halo.app.extension.index.query.QueryFactory.and;
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.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
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.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.exception.OAuth2UserAlreadyBoundException;
import run.halo.app.infra.utils.JsonUtils;

@Service
public class UserConnectionServiceImpl implements UserConnectionService {

private final ReactiveExtensionClient client;

private Clock clock = Clock.systemDefaultZone();

public UserConnectionServiceImpl(ReactiveExtensionClient client) {
this.client = client;
}

void setClock(Clock clock) {
this.clock = clock;
}

@Override
public Mono<UserConnection> createUserConnection(
String username,
String registrationId,
OAuth2User oauth2User
) {
return getUserConnection(registrationId, username)
.flatMap(connection -> Mono.<UserConnection>error(
() -> new OAuth2UserAlreadyBoundException(connection))
)
.switchIfEmpty(Mono.defer(() -> {
var connection = new UserConnection();
connection.setMetadata(new Metadata());
var metadata = connection.getMetadata();
updateUserInfo(metadata, oauth2User);
metadata.setGenerateName(username + "-");
connection.setSpec(new UserConnectionSpec());
var spec = connection.getSpec();
spec.setUsername(username);
spec.setProviderUserId(oauth2User.getName());
spec.setRegistrationId(registrationId);
spec.setUpdatedAt(clock.instant());
return client.create(connection);
}));
}

private Mono<UserConnection> updateUserConnection(UserConnection connection,
OAuth2User oauth2User) {
connection.getSpec().setUpdatedAt(clock.instant());
updateUserInfo(connection.getMetadata(), oauth2User);
return client.update(connection);
}

private Mono<UserConnection> getUserConnection(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()).next();
}

@Override
public Mono<UserConnection> updateUserConnectionIfPresent(String registrationId,
OAuth2User oauth2User) {
var listOptions = ListOptions.builder()
.fieldQuery(and(
equal("spec.registrationId", registrationId),
equal("spec.providerUserId", oauth2User.getName())
))
.build();
return client.listAll(UserConnection.class, listOptions, defaultSort()).next()
.flatMap(connection -> updateUserConnection(connection, oauth2User));
}

private void updateUserInfo(MetadataOperator metadata, OAuth2User oauth2User) {
var annotations = Optional.ofNullable(metadata.getAnnotations())
.orElseGet(HashMap::new);
metadata.setAnnotations(annotations);
annotations.put(
"auth.halo.run/oauth2-user-info",
JsonUtils.objectToJson(oauth2User.getAttributes())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,22 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event
.map(UserConnectionSpec::getUsername)
.orElse(null)
)));
is.add(new IndexSpec()
.setName("spec.registrationId")
.setIndexFunc(simpleAttribute(UserConnection.class,
connection -> Optional.ofNullable(connection.getSpec())
.map(UserConnectionSpec::getRegistrationId)
.orElse(null)
))
);
is.add(new IndexSpec()
.setName("spec.providerUserId")
.setIndexFunc(simpleAttribute(UserConnection.class,
connection -> Optional.ofNullable(connection.getSpec())
.map(UserConnectionSpec::getProviderUserId)
.orElse(null)
))
);
});

// security.halo.run
Expand Down
Loading

0 comments on commit e779543

Please sign in to comment.