Add support for serializing or deserializing HaloUser and 2FA (#6005)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.16.x

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

This PR adds support for serializing HaloUser and 2FA.

1. Refactor delegate of HaloUser using `org.springframework.security.core.userdetails.User`.
2. Add `HaloSecurityJackson2Module` to enable serialization/deserialization of Halo security module.

Below is code snippet of integration:

```java
        this.objectMapper = Jackson2ObjectMapperBuilder.json()
            .modules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader()))
            .modules(modules -> modules.add(new HaloSecurityJackson2Module()))
            .indentOutput(true)
            .build();
```

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

```release-note
None
```
pull/6007/head
John Niang 2024-05-28 17:13:06 +08:00 committed by GitHub
parent afabffc546
commit dad6610cce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 270 additions and 82 deletions

View File

@ -1,5 +1,6 @@
package run.halo.app.security;
import static java.util.Objects.requireNonNullElse;
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
@ -48,19 +49,27 @@ public class DefaultUserDetailService
.flatMap(user -> {
var name = user.getMetadata().getName();
var subject = new Subject(KIND, name, GROUP);
var builder = new HaloUser.Builder(user);
var userBuilder = User.withUsername(name)
.password(user.getSpec().getPassword())
.disabled(requireNonNullElse(user.getSpec().getDisabled(), false));
var setAuthorities = roleService.listRoleRefs(subject)
.filter(this::isRoleRef)
.map(RoleRef::getName)
// every authenticated user should have authenticated and anonymous roles.
.concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME)
.map(roleName -> new SimpleGrantedAuthority(ROLE_PREFIX + roleName))
.distinct()
.collectList()
.doOnNext(builder::authorities);
.doOnNext(userBuilder::authorities);
return setAuthorities.then(Mono.fromSupplier(builder::build));
return setAuthorities.then(Mono.fromSupplier(() -> {
var twoFactorAuthEnabled =
requireNonNullElse(user.getSpec().getTwoFactorAuthEnabled(), false);
return new HaloUser.Builder(userBuilder.build())
.twoFactorAuthEnabled(twoFactorAuthEnabled)
.totpEncryptedSecret(user.getSpec().getTotpEncryptedSecret())
.build();
}));
});
}

View File

@ -0,0 +1,21 @@
package run.halo.app.security;
import org.springframework.security.core.userdetails.UserDetails;
public interface HaloUserDetails extends UserDetails {
/**
* Checks if two-factor authentication is enabled.
*
* @return true if two-factor authentication is enabled, false otherwise.
*/
boolean isTwoFactorAuthEnabled();
/**
* Gets the encrypted secret of TOTP.
*
* @return encrypted secret of TOTP.
*/
String getTotpEncryptedSecret();
}

View File

@ -1,113 +1,120 @@
package run.halo.app.security.authentication.login;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.Assert;
import run.halo.app.core.extension.User;
import run.halo.app.security.HaloUserDetails;
public class HaloUser implements UserDetails, CredentialsContainer {
public class HaloUser implements HaloUserDetails, CredentialsContainer {
private final User delegate;
private final UserDetails delegate;
private final Collection<? extends GrantedAuthority> authorities;
private final boolean twoFactorAuthEnabled;
public HaloUser(User delegate, Collection<? extends GrantedAuthority> authorities) {
private String totpEncryptedSecret;
public HaloUser(UserDetails delegate,
boolean twoFactorAuthEnabled,
String totpEncryptedSecret) {
Assert.notNull(delegate, "Delegate user must not be null");
Assert.notNull(authorities, "Authorities must not be null");
this.delegate = delegate;
this.authorities = authorities.stream()
.filter(Objects::nonNull)
.sorted(Comparator.comparing(GrantedAuthority::getAuthority))
.toList();
}
public HaloUser(User delegate) {
this(delegate, List.of());
this.twoFactorAuthEnabled = twoFactorAuthEnabled;
this.totpEncryptedSecret = totpEncryptedSecret;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
return delegate.getAuthorities();
}
@Override
public String getPassword() {
return delegate.getSpec().getPassword();
return delegate.getPassword();
}
@Override
public String getUsername() {
return delegate.getMetadata().getName();
return delegate.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
return delegate.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return true;
return delegate.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return true;
return delegate.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
var disabled = delegate.getSpec().getDisabled();
return disabled == null || !disabled;
}
public User getDelegate() {
return delegate;
return delegate.isEnabled();
}
@Override
public void eraseCredentials() {
delegate.getSpec().setPassword(null);
if (delegate instanceof CredentialsContainer container) {
container.eraseCredentials();
}
this.totpEncryptedSecret = null;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof HaloUser user) {
var username = this.delegate.getMetadata().getName();
var otherUsername = user.delegate.getMetadata().getName();
return username.equals(otherUsername);
return Objects.equals(this.delegate, user.delegate);
}
return false;
}
@Override
public int hashCode() {
return this.delegate.getMetadata().getName().hashCode();
return this.delegate.hashCode();
}
@Override
public boolean isTwoFactorAuthEnabled() {
return this.twoFactorAuthEnabled;
}
@Override
public String getTotpEncryptedSecret() {
return this.totpEncryptedSecret;
}
public static class Builder {
private final User user;
private final UserDetails user;
private Collection<? extends GrantedAuthority> authorities;
private boolean twoFactorAuthEnabled;
public Builder(User user) {
private String totpEncryptedSecret;
public Builder(UserDetails user) {
this.user = user;
}
public Builder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
public Builder twoFactorAuthEnabled(boolean twoFactorAuthEnabled) {
this.twoFactorAuthEnabled = twoFactorAuthEnabled;
return this;
}
public HaloUser build() {
return new HaloUser(user, authorities);
public Builder totpEncryptedSecret(String totpEncryptedSecret) {
this.totpEncryptedSecret = totpEncryptedSecret;
return this;
}
public HaloUserDetails build() {
return new HaloUser(user, twoFactorAuthEnabled, totpEncryptedSecret);
}
}
}

View File

@ -6,8 +6,8 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.HaloUserDetails;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
import run.halo.app.security.authentication.twofactor.TwoFactorUtils;
@Slf4j
public class UsernamePasswordDelegatingAuthenticationManager
@ -43,12 +43,9 @@ public class UsernamePasswordDelegatingAuthenticationManager
)
// check if MFA is enabled after authenticated
.map(a -> {
if (a.getPrincipal() instanceof HaloUser user) {
var twoFactorAuthSettings =
TwoFactorUtils.getTwoFactorAuthSettings(user.getDelegate());
if (twoFactorAuthSettings.isAvailable()) {
a = new TwoFactorAuthentication(a);
}
if (a.getPrincipal() instanceof HaloUserDetails user
&& user.isTwoFactorAuthEnabled()) {
a = new TwoFactorAuthentication(a);
}
return a;
});

View File

@ -71,6 +71,10 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
.then(webFilterExchange.getChain().filter(webFilterExchange.getExchange()));
}
if (authentication instanceof CredentialsContainer container) {
container.eraseCredentials();
}
ServerWebExchangeMatcher xhrMatcher = exchange -> {
if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
.contains("XMLHttpRequest")) {
@ -87,14 +91,9 @@ public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandl
() -> defaultSuccessHandler.onAuthenticationSuccess(webFilterExchange,
authentication)
.then(Mono.empty())))
.flatMap(isXhr -> {
if (authentication instanceof CredentialsContainer container) {
container.eraseCredentials();
}
return ServerResponse.ok()
.bodyValue(authentication.getPrincipal())
.flatMap(response -> response.writeTo(exchange, context));
}));
.flatMap(isXhr -> ServerResponse.ok()
.bodyValue(authentication.getPrincipal())
.flatMap(response -> response.writeTo(exchange, context))));
}
private Mono<Void> handleAuthenticationException(Throwable exception,

View File

@ -15,6 +15,7 @@ import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFil
public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
private final ServerSecurityContextRepository securityContextRepository;
private final TotpAuthService totpAuthService;
private final ServerResponse.Context context;
@ -25,8 +26,11 @@ public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
public TwoFactorAuthSecurityConfigurer(
ServerSecurityContextRepository securityContextRepository,
TotpAuthService totpAuthService, ServerResponse.Context context,
MessageSource messageSource, RememberMeServices rememberMeServices) {
TotpAuthService totpAuthService,
ServerResponse.Context context,
MessageSource messageSource,
RememberMeServices rememberMeServices
) {
this.securityContextRepository = securityContextRepository;
this.totpAuthService = totpAuthService;
this.context = context;

View File

@ -7,6 +7,11 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication token for two-factor authentication.
*
* @author johnniang
*/
public class TwoFactorAuthentication extends AbstractAuthenticationToken {
private final Authentication previous;
@ -33,10 +38,12 @@ public class TwoFactorAuthentication extends AbstractAuthenticationToken {
@Override
public boolean isAuthenticated() {
// return true for accessing anonymous resources
return true;
}
public Authentication getPrevious() {
return previous;
}
}

View File

@ -9,6 +9,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
@ -17,7 +18,7 @@ import org.springframework.security.web.server.context.ServerSecurityContextRepo
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.login.HaloUser;
import run.halo.app.security.HaloUserDetails;
import run.halo.app.security.authentication.login.UsernamePasswordHandler;
import run.halo.app.security.authentication.rememberme.RememberMeServices;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
@ -25,11 +26,13 @@ import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
@Slf4j
public class TotpAuthenticationFilter extends AuthenticationWebFilter {
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
public TotpAuthenticationFilter(
ServerSecurityContextRepository securityContextRepository,
TotpAuthService totpAuthService,
ServerResponse.Context context,
MessageSource messageSource,
RememberMeServices rememberMeServices) {
RememberMeServices rememberMeServices
) {
super(new TwoFactorAuthManager(totpAuthService));
setSecurityContextRepository(securityContextRepository);
@ -96,36 +99,38 @@ public class TotpAuthenticationFilter extends AuthenticationWebFilter {
// it should be TotpAuthenticationToken
var code = (Integer) authentication.getCredentials();
log.debug("Got TOTP code {}", code);
// get user details
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.cast(TwoFactorAuthentication.class)
.map(TwoFactorAuthentication::getPrevious)
.<Authentication>handle((prevAuth, sink) -> {
var principal = prevAuth.getPrincipal();
if (!(principal instanceof HaloUser haloUser)) {
sink.error(new TwoFactorAuthException("Invalid MFA authentication."));
return;
.flatMap(previousAuth -> {
var principal = previousAuth.getPrincipal();
if (!(principal instanceof HaloUserDetails user)) {
return Mono.error(
new TwoFactorAuthException("Invalid authentication principal.")
);
}
var encryptedSecret =
haloUser.getDelegate().getSpec().getTotpEncryptedSecret();
if (StringUtils.isBlank(encryptedSecret)) {
sink.error(new TwoFactorAuthException("Empty secret configured"));
return;
var totpEncryptedSecret = user.getTotpEncryptedSecret();
if (StringUtils.isBlank(totpEncryptedSecret)) {
return Mono.error(
new TwoFactorAuthException("TOTP secret not configured.")
);
}
var rawSecret = totpAuthService.decryptSecret(encryptedSecret);
var rawSecret = totpAuthService.decryptSecret(totpEncryptedSecret);
var validated = totpAuthService.validateTotp(rawSecret, code);
if (!validated) {
sink.error(new TwoFactorAuthException("Invalid TOTP code " + code));
return;
return Mono.error(new TwoFactorAuthException("Invalid TOTP code " + code));
}
sink.next(prevAuth);
})
.doOnNext(previousAuth -> {
if (log.isDebugEnabled()) {
log.debug("TOTP authentication for {} with code {} successfully.",
previousAuth.getName(), code);
}
if (previousAuth instanceof CredentialsContainer container) {
container.eraseCredentials();
}
return Mono.just(previousAuth);
});
}
}

View File

@ -0,0 +1,28 @@
package run.halo.app.security.jackson2;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import run.halo.app.security.authentication.login.HaloUser;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
/**
* Halo security Jackson2 module.
*
* @author johnniang
*/
public class HaloSecurityJackson2Module extends SimpleModule {
public HaloSecurityJackson2Module() {
super(HaloSecurityJackson2Module.class.getName(), new Version(1, 0, 0, null, null, null));
}
@Override
public void setupModule(SetupContext context) {
SecurityJackson2Modules.enableDefaultTyping(context.getOwner());
context.setMixInAnnotations(HaloUser.class, HaloUserMixin.class);
context.setMixInAnnotations(TwoFactorAuthentication.class,
TwoFactorAuthenticationMixin.class);
}
}

View File

@ -0,0 +1,20 @@
package run.halo.app.security.jackson2;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.springframework.security.core.userdetails.UserDetails;
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility =
JsonAutoDetect.Visibility.NONE, isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
abstract class HaloUserMixin {
HaloUserMixin(@JsonProperty("delegate") UserDetails delegate,
@JsonProperty("twoFactorAuthEnabled") boolean twoFactorAuthEnabled,
@JsonProperty("totpEncryptedSecret") String totpEncryptedSecret) {
}
}

View File

@ -0,0 +1,25 @@
package run.halo.app.security.jackson2;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.springframework.security.core.Authentication;
/**
* This mixin class is used to serialize/deserialize TwoFactorAuthentication.
*
* @author johnniang
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
abstract class TwoFactorAuthenticationMixin {
@JsonCreator
TwoFactorAuthenticationMixin(@JsonProperty("previous") Authentication previous) {
}
}

View File

@ -0,0 +1,66 @@
package run.halo.app.security.jackson2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.jackson2.SecurityJackson2Modules;
import run.halo.app.security.authentication.login.HaloUser;
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
class HaloSecurityJacksonModuleTest {
ObjectMapper objectMapper;
@BeforeEach
void setUp() {
this.objectMapper = Jackson2ObjectMapperBuilder.json()
.modules(SecurityJackson2Modules.getModules(this.getClass().getClassLoader()))
.modules(modules -> modules.add(new HaloSecurityJackson2Module()))
.indentOutput(true)
.build();
}
@Test
void codecHaloUserTest() throws JsonProcessingException {
codecAssert(haloUser -> UsernamePasswordAuthenticationToken.authenticated(haloUser,
haloUser.getPassword(),
haloUser.getAuthorities()));
}
@Test
void codecTwoFactorAuthenticationTokenTest() throws JsonProcessingException {
codecAssert(haloUser -> new TwoFactorAuthentication(
UsernamePasswordAuthenticationToken.authenticated(haloUser,
haloUser.getPassword(),
haloUser.getAuthorities())));
}
void codecAssert(Function<HaloUser, Authentication> authenticationConverter)
throws JsonProcessingException {
var userDetails = User.withUsername("faker")
.password("123456")
.authorities("ROLE_USER")
.build();
var haloUser = new HaloUser(userDetails, true, "fake-encrypted-secret");
var authentication = authenticationConverter.apply(haloUser);
var securityContext = new SecurityContextImpl(authentication);
var securityContextJson = objectMapper.writeValueAsString(securityContext);
var deserializedSecurityContext =
objectMapper.readValue(securityContextJson, SecurityContext.class);
assertEquals(deserializedSecurityContext, securityContext);
}
}