diff --git a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java index 528f5253b..2d7f6ff84 100644 --- a/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java +++ b/application/src/main/java/run/halo/app/security/DefaultUserDetailService.java @@ -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(); + })); }); } diff --git a/application/src/main/java/run/halo/app/security/HaloUserDetails.java b/application/src/main/java/run/halo/app/security/HaloUserDetails.java new file mode 100644 index 000000000..927ce8e8b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/HaloUserDetails.java @@ -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(); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java index 6104f14a8..508dc84f6 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/HaloUser.java @@ -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 authorities; + private final boolean twoFactorAuthEnabled; - public HaloUser(User delegate, Collection 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 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 authorities; + private boolean twoFactorAuthEnabled; - public Builder(User user) { + private String totpEncryptedSecret; + + public Builder(UserDetails user) { this.user = user; } - public Builder authorities(Collection 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); } } } diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java index 4a2ba58d5..1c54ab672 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordDelegatingAuthenticationManager.java @@ -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; }); diff --git a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java index cb7f3d0c4..43ac25b09 100644 --- a/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java +++ b/application/src/main/java/run/halo/app/security/authentication/login/UsernamePasswordHandler.java @@ -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 handleAuthenticationException(Throwable exception, diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java index a271df9c9..09a9f1e53 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthSecurityConfigurer.java @@ -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; diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java index 76a679e06..45a27e66b 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/TwoFactorAuthentication.java @@ -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; } + } diff --git a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java index ab37cf17d..a0b8c0723 100644 --- a/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java +++ b/application/src/main/java/run/halo/app/security/authentication/twofactor/totp/TotpAuthenticationFilter.java @@ -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) - .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); }); } } diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java new file mode 100644 index 000000000..ff3687f77 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloSecurityJackson2Module.java @@ -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); + } + +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java b/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java new file mode 100644 index 000000000..8f84ed2a6 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/HaloUserMixin.java @@ -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) { + } + +} diff --git a/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java new file mode 100644 index 000000000..71c1f737e --- /dev/null +++ b/application/src/main/java/run/halo/app/security/jackson2/TwoFactorAuthenticationMixin.java @@ -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) { + } +} diff --git a/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java new file mode 100644 index 000000000..55804bbf7 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/jackson2/HaloSecurityJacksonModuleTest.java @@ -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 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); + } +}