mirror of https://github.com/halo-dev/halo
Support TOTP two-factor authentication for backend
Signed-off-by: John Niang <johnniang@foxmail.com>pull/4737/head
parent
5fab8aca5a
commit
7946585bb5
|
@ -63,6 +63,8 @@ dependencies {
|
|||
api "io.github.resilience4j:resilience4j-spring-boot3"
|
||||
api "io.github.resilience4j:resilience4j-reactor"
|
||||
|
||||
api "com.j256.two-factor-auth:two-factor-auth"
|
||||
|
||||
runtimeOnly 'io.r2dbc:r2dbc-h2'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
runtimeOnly 'org.postgresql:r2dbc-postgresql'
|
||||
|
|
|
@ -45,9 +45,9 @@ public class User extends AbstractExtension {
|
|||
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
|
||||
|
||||
@Schema(requiredMode = REQUIRED)
|
||||
private UserSpec spec;
|
||||
private UserSpec spec = new UserSpec();
|
||||
|
||||
private UserStatus status;
|
||||
private UserStatus status = new UserStatus();
|
||||
|
||||
@Data
|
||||
public static class UserSpec {
|
||||
|
@ -72,6 +72,8 @@ public class User extends AbstractExtension {
|
|||
|
||||
private Boolean twoFactorAuthEnabled;
|
||||
|
||||
private String totpEncryptedSecret;
|
||||
|
||||
private Boolean disabled;
|
||||
|
||||
private Integer loginHistoryLimit;
|
||||
|
|
|
@ -31,7 +31,6 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||
import run.halo.app.infra.AnonymousUserConst;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.security.DefaultServerAuthenticationEntryPoint;
|
||||
import run.halo.app.security.DefaultUserDetailService;
|
||||
import run.halo.app.security.DynamicMatcherSecurityWebFilterChain;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
|
@ -42,6 +41,7 @@ import run.halo.app.security.authentication.login.impl.RsaKeyService;
|
|||
import run.halo.app.security.authentication.pat.PatAuthenticationManager;
|
||||
import run.halo.app.security.authentication.pat.PatJwkSupplier;
|
||||
import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthorizationManager;
|
||||
import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
|
||||
|
||||
/**
|
||||
|
@ -67,7 +67,11 @@ public class WebServerSecurityConfig {
|
|||
http.securityMatcher(pathMatchers("/api/**", "/apis/**", "/oauth2/**",
|
||||
"/login/**", "/logout", "/actuator/**"))
|
||||
.authorizeExchange(spec -> {
|
||||
spec.anyExchange().access(new RequestInfoAuthorizationManager(roleService));
|
||||
spec.anyExchange().access(
|
||||
new TwoFactorAuthorizationManager(
|
||||
new RequestInfoAuthorizationManager(roleService)
|
||||
)
|
||||
);
|
||||
})
|
||||
.anonymous(spec -> {
|
||||
spec.authorities(AnonymousUserConst.Role);
|
||||
|
@ -79,12 +83,11 @@ public class WebServerSecurityConfig {
|
|||
var authManagerResolver = builder().add(
|
||||
new PatServerWebExchangeMatcher(),
|
||||
new PatAuthenticationManager(client, patJwkSupplier))
|
||||
// TODO Add other authentication mangers here. e.g.: JwtAuthentiationManager.
|
||||
// TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager.
|
||||
.build();
|
||||
oauth2.authenticationManagerResolver(authManagerResolver);
|
||||
})
|
||||
.exceptionHandling(
|
||||
spec -> spec.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint()));
|
||||
;
|
||||
|
||||
// Integrate with other configurers separately
|
||||
securityConfigurers.orderedStream()
|
||||
|
|
|
@ -82,11 +82,13 @@ import run.halo.app.extension.Metadata;
|
|||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.infra.AnonymousUserConst;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.ValidationUtils;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
|
@ -542,10 +544,11 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
@NonNull
|
||||
Mono<ServerResponse> me(ServerRequest request) {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.flatMap(ctx -> {
|
||||
var name = ctx.getAuthentication().getName();
|
||||
return userService.getUser(name);
|
||||
})
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.filter(obj -> !(obj instanceof TwoFactorAuthentication))
|
||||
.map(Authentication::getName)
|
||||
.defaultIfEmpty(AnonymousUserConst.PRINCIPAL)
|
||||
.flatMap(userService::getUser)
|
||||
.flatMap(this::toDetailedUser)
|
||||
.flatMap(user -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
package run.halo.app.core.extension.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||
import run.halo.app.security.authorization.AuthorityUtils;
|
||||
|
||||
/**
|
||||
* <p>Obtain the authorities from the authenticated authentication and construct it as a RoleBinding
|
||||
* list.</p>
|
||||
* <p>After JWT authentication, the roles stored in the authorities are the roles owned by the user,
|
||||
* so there is no need to query from the database.</p>
|
||||
* <p>For tokens in other formats, after authentication, fill the authorities with the token into
|
||||
* the SecurityContextHolder.</p>
|
||||
*
|
||||
* @author guqing
|
||||
* @see AnonymousAuthenticationFilter
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
public class DefaultRoleBindingService implements RoleBindingService {
|
||||
private static final String SCOPE_AUTHORITY_PREFIX = AuthorityUtils.SCOPE_PREFIX;
|
||||
private static final String ROLE_AUTHORITY_PREFIX = AuthorityUtils.ROLE_PREFIX;
|
||||
|
||||
@Override
|
||||
public Set<String> listBoundRoleNames(Collection<? extends GrantedAuthority> authorities) {
|
||||
return authorities.stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
// Exclude anonymous user roles
|
||||
.filter(authority -> !authority.equals("ROLE_ANONYMOUS"))
|
||||
.map(scope -> {
|
||||
if (scope.startsWith(SCOPE_AUTHORITY_PREFIX)) {
|
||||
scope = scope.replaceFirst(SCOPE_AUTHORITY_PREFIX, "");
|
||||
// keep checking the ROLE_ here
|
||||
}
|
||||
if (scope.startsWith(ROLE_AUTHORITY_PREFIX)) {
|
||||
return scope.replaceFirst(ROLE_AUTHORITY_PREFIX, "");
|
||||
}
|
||||
return scope;
|
||||
})
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package run.halo.app.core.extension.service;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RoleBindingService {
|
||||
|
||||
Set<String> listBoundRoleNames(Collection<? extends GrantedAuthority> authorities);
|
||||
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import org.springframework.web.util.BindErrorUtils;
|
||||
|
||||
public class RequestBodyValidationException extends ServerWebInputException {
|
||||
|
||||
private final BindingResult bindingResult;
|
||||
|
||||
public RequestBodyValidationException(BindingResult bindingResult) {
|
||||
super("Validation failure", null, null, null, null);
|
||||
this.bindingResult = bindingResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProblemDetail updateAndGetBody(MessageSource messageSource, Locale locale) {
|
||||
var detail = super.updateAndGetBody(messageSource, locale);
|
||||
detail.setProperty("errors", collectAllErrors(messageSource, locale));
|
||||
return detail;
|
||||
}
|
||||
|
||||
private List<String> collectAllErrors(MessageSource messageSource, Locale locale) {
|
||||
var globalErrors = resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale);
|
||||
var fieldErrors = resolveErrors(bindingResult.getFieldErrors(), messageSource, locale);
|
||||
var errors = new ArrayList<String>(globalErrors.size() + fieldErrors.size());
|
||||
errors.addAll(globalErrors);
|
||||
errors.addAll(fieldErrors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object[] getDetailMessageArguments(MessageSource messageSource, Locale locale) {
|
||||
return new Object[] {
|
||||
resolveErrors(bindingResult.getGlobalErrors(), messageSource, locale),
|
||||
resolveErrors(bindingResult.getFieldErrors(), messageSource, locale)
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object[] getDetailMessageArguments() {
|
||||
return new Object[] {
|
||||
resolveErrors(bindingResult.getGlobalErrors(), null, Locale.getDefault()),
|
||||
resolveErrors(bindingResult.getFieldErrors(), null, Locale.getDefault())
|
||||
};
|
||||
}
|
||||
|
||||
private static List<String> resolveErrors(
|
||||
List<? extends MessageSourceResolvable> errors,
|
||||
@Nullable MessageSource messageSource,
|
||||
Locale locale) {
|
||||
return messageSource == null
|
||||
? BindErrorUtils.resolve(errors).values().stream().toList()
|
||||
: BindErrorUtils.resolve(errors, messageSource, locale).values().stream().toList();
|
||||
}
|
||||
}
|
|
@ -2,8 +2,12 @@ package run.halo.app.security;
|
|||
|
||||
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;
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME;
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX;
|
||||
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
|
@ -16,6 +20,7 @@ import run.halo.app.core.extension.service.RoleService;
|
|||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.GroupKind;
|
||||
import run.halo.app.infra.exception.UserNotFoundException;
|
||||
import run.halo.app.security.authentication.login.HaloUser;
|
||||
|
||||
public class DefaultUserDetailService
|
||||
implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
|
||||
|
@ -43,15 +48,19 @@ public class DefaultUserDetailService
|
|||
.flatMap(user -> {
|
||||
var name = user.getMetadata().getName();
|
||||
var subject = new Subject(KIND, name, GROUP);
|
||||
return roleService.listRoleRefs(subject)
|
||||
|
||||
var builder = new HaloUser.Builder(user);
|
||||
|
||||
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))
|
||||
.collectList()
|
||||
.map(roleNames -> User.builder()
|
||||
.username(name)
|
||||
.password(user.getSpec().getPassword())
|
||||
.roles(roleNames.toArray(new String[0]))
|
||||
.build());
|
||||
.doOnNext(builder::authorities);
|
||||
|
||||
return setAuthorities.then(Mono.fromSupplier(builder::build));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
|
||||
@Component
|
||||
public class ExceptionSecurityConfigurer implements SecurityConfigurer {
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
http.exceptionHandling(exception -> {
|
||||
var accessDeniedHandler = new BearerTokenServerAccessDeniedHandler();
|
||||
var entryPoint = new DefaultServerAuthenticationEntryPoint();
|
||||
exception
|
||||
.authenticationEntryPoint(entryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import static org.springframework.security.config.web.server.SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING;
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
|
||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
|
||||
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
|
||||
@Component
|
||||
public class LogoutSecurityConfigurer implements SecurityConfigurer {
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
http.logout(logout -> logout.logoutSuccessHandler(new LogoutSuccessHandler()));
|
||||
http.addFilterAt(new LogoutPageGeneratingWebFilter(), LOGOUT_PAGE_GENERATING);
|
||||
}
|
||||
|
||||
public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
||||
|
||||
private final ServerLogoutSuccessHandler defaultHandler;
|
||||
|
||||
public LogoutSuccessHandler() {
|
||||
var defaultHandler = new RedirectServerLogoutSuccessHandler();
|
||||
defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout"));
|
||||
this.defaultHandler = defaultHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange,
|
||||
Authentication authentication) {
|
||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
|
||||
.flatMap(matchResult -> {
|
||||
if (matchResult.isMatch()) {
|
||||
var response = exchange.getExchange().getResponse();
|
||||
response.setStatusCode(HttpStatus.NO_CONTENT);
|
||||
return response.setComplete();
|
||||
}
|
||||
return defaultHandler.onLogoutSuccess(exchange, authentication);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
package run.halo.app.security.authentication.jwt;
|
||||
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME;
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
|
@ -7,9 +11,7 @@ import org.springframework.security.core.GrantedAuthority;
|
|||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import run.halo.app.security.authorization.AuthorityUtils;
|
||||
|
||||
/**
|
||||
* GrantedAuthorities converter for SCOPE_ and ROLE_ prefixes.
|
||||
|
@ -17,7 +19,7 @@ import run.halo.app.security.authorization.AuthorityUtils;
|
|||
* @author johnniang
|
||||
*/
|
||||
public class JwtScopesAndRolesGrantedAuthoritiesConverter
|
||||
implements Converter<Jwt, Flux<GrantedAuthority>> {
|
||||
implements Converter<Jwt, Flux<GrantedAuthority>> {
|
||||
|
||||
private final Converter<Jwt, Collection<GrantedAuthority>> delegate;
|
||||
|
||||
|
@ -28,17 +30,23 @@ public class JwtScopesAndRolesGrantedAuthoritiesConverter
|
|||
@Override
|
||||
public Flux<GrantedAuthority> convert(Jwt jwt) {
|
||||
var grantedAuthorities = new ArrayList<GrantedAuthority>();
|
||||
|
||||
// add default roles
|
||||
grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + ANONYMOUS_ROLE_NAME));
|
||||
grantedAuthorities.add(new SimpleGrantedAuthority(ROLE_PREFIX + AUTHENTICATED_ROLE_NAME));
|
||||
|
||||
var delegateAuthorities = delegate.convert(jwt);
|
||||
if (delegateAuthorities != null) {
|
||||
grantedAuthorities.addAll(delegateAuthorities);
|
||||
}
|
||||
var roles = jwt.getClaimAsStringList("roles");
|
||||
if (!CollectionUtils.isEmpty(roles)) {
|
||||
if (roles != null) {
|
||||
roles.stream()
|
||||
.map(role -> AuthorityUtils.ROLE_PREFIX + role)
|
||||
.map(role -> ROLE_PREFIX + role)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.forEach(grantedAuthorities::add);
|
||||
}
|
||||
|
||||
return Flux.fromIterable(grantedAuthorities);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
|
||||
/**
|
||||
* Generates a default log out page.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@Component
|
||||
public class DelegatingLogoutPageGeneratingWebFilter implements AdditionalWebFilter {
|
||||
private final LogoutPageGeneratingWebFilter logoutPageGeneratingWebFilter =
|
||||
new LogoutPageGeneratingWebFilter();
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||
return logoutPageGeneratingWebFilter.filter(exchange, chain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return SecurityWebFiltersOrder.LOGOUT_PAGE_GENERATING.getOrder();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
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;
|
||||
|
||||
public class HaloUser implements UserDetails, CredentialsContainer {
|
||||
|
||||
private final User delegate;
|
||||
|
||||
private final Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
public HaloUser(User delegate, Collection<? extends GrantedAuthority> authorities) {
|
||||
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());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return delegate.getSpec().getPassword();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return delegate.getMetadata().getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
var disabled = delegate.getSpec().getDisabled();
|
||||
return disabled == null || !disabled;
|
||||
}
|
||||
|
||||
public User getDelegate() {
|
||||
return delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void eraseCredentials() {
|
||||
delegate.getSpec().setPassword(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 false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.delegate.getMetadata().getName().hashCode();
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
|
||||
private final User user;
|
||||
|
||||
private Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
public Builder(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public Builder authorities(Collection<? extends GrantedAuthority> authorities) {
|
||||
this.authorities = authorities;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HaloUser build() {
|
||||
return new HaloUser(user, authorities);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,27 +2,38 @@ package run.halo.app.security.authentication.login;
|
|||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||
import java.util.Base64;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.authentication.ServerFormLoginAuthenticationConverter;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.IpAddressUtils;
|
||||
|
||||
@Slf4j
|
||||
public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationConverter {
|
||||
|
||||
private final CryptoService cryptoService;
|
||||
|
||||
public LoginAuthenticationConverter(CryptoService cryptoService) {
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
public LoginAuthenticationConverter(CryptoService cryptoService,
|
||||
RateLimiterRegistry rateLimiterRegistry) {
|
||||
this.cryptoService = cryptoService;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||
return super.convert(exchange)
|
||||
// validate the password
|
||||
.flatMap(token -> {
|
||||
.<Authentication>flatMap(token -> {
|
||||
var credentials = (String) token.getCredentials();
|
||||
byte[] credentialsBytes;
|
||||
try {
|
||||
|
@ -37,6 +48,23 @@ public class LoginAuthenticationConverter extends ServerFormLoginAuthenticationC
|
|||
.map(decryptedCredentials -> new UsernamePasswordAuthenticationToken(
|
||||
token.getPrincipal(),
|
||||
new String(decryptedCredentials, UTF_8)));
|
||||
});
|
||||
})
|
||||
.transformDeferred(createIpBasedRateLimiter(exchange))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
|
||||
}
|
||||
|
||||
private <T> RateLimiterOperator<T> createIpBasedRateLimiter(ServerWebExchange exchange) {
|
||||
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
|
||||
var rateLimiter = rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp,
|
||||
"authentication");
|
||||
if (log.isDebugEnabled()) {
|
||||
var metrics = rateLimiter.getMetrics();
|
||||
log.debug(
|
||||
"Authentication with Rate Limiter: {}, available permissions: {}, number of "
|
||||
+ "waiting threads: {}",
|
||||
rateLimiter, metrics.getAvailablePermissions(),
|
||||
metrics.getNumberOfWaitingThreads());
|
||||
}
|
||||
return RateLimiterOperator.of(rateLimiter);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,6 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static run.halo.app.infra.exception.Exceptions.createErrorResponse;
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
@ -17,29 +10,17 @@ import org.springframework.security.authentication.ObservationReactiveAuthentica
|
|||
import org.springframework.security.authentication.ReactiveAuthenticationManager;
|
||||
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.CredentialsContainer;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
|
||||
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
import run.halo.app.infra.utils.IpAddressUtils;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
|
||||
|
@ -53,8 +34,6 @@ import run.halo.app.security.AdditionalWebFilter;
|
|||
@Component
|
||||
public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final ObservationRegistry observationRegistry;
|
||||
|
||||
private final ReactiveUserDetailsService userDetailsService;
|
||||
|
@ -69,9 +48,6 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
|
||||
private final AuthenticationWebFilter authenticationWebFilter;
|
||||
|
||||
private final RateLimiterRegistry rateLimiterRegistry;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
|
||||
public UsernamePasswordAuthenticator(ServerResponse.Context context,
|
||||
|
@ -80,20 +56,17 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
ServerSecurityContextRepository securityContextRepository, CryptoService cryptoService,
|
||||
RateLimiterRegistry rateLimiterRegistry, MessageSource messageSource,
|
||||
ExtensionGetter extensionGetter) {
|
||||
this.context = context;
|
||||
this.observationRegistry = observationRegistry;
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.passwordService = passwordService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
this.cryptoService = cryptoService;
|
||||
this.rateLimiterRegistry = rateLimiterRegistry;
|
||||
this.messageSource = messageSource;
|
||||
this.extensionGetter = extensionGetter;
|
||||
|
||||
this.authenticationWebFilter =
|
||||
new UsernamePasswordAuthenticationWebFilter(authenticationManager());
|
||||
configureAuthenticationWebFilter(this.authenticationWebFilter);
|
||||
this.authenticationWebFilter = new AuthenticationWebFilter(authenticationManager());
|
||||
configureAuthenticationWebFilter(this.authenticationWebFilter, context, messageSource,
|
||||
rateLimiterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -107,13 +80,17 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
return SecurityWebFiltersOrder.FORM_LOGIN.getOrder();
|
||||
}
|
||||
|
||||
void configureAuthenticationWebFilter(AuthenticationWebFilter filter) {
|
||||
void configureAuthenticationWebFilter(AuthenticationWebFilter filter,
|
||||
ServerResponse.Context context,
|
||||
MessageSource messageSource,
|
||||
RateLimiterRegistry rateLimiterRegistry) {
|
||||
var requiresMatcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login");
|
||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
||||
var authConverter = new LoginAuthenticationConverter(cryptoService, rateLimiterRegistry);
|
||||
filter.setRequiresAuthenticationMatcher(requiresMatcher);
|
||||
filter.setAuthenticationFailureHandler(new LoginFailureHandler());
|
||||
filter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
|
||||
filter.setServerAuthenticationConverter(new LoginAuthenticationConverter(cryptoService
|
||||
));
|
||||
filter.setAuthenticationFailureHandler(handler);
|
||||
filter.setAuthenticationSuccessHandler(handler);
|
||||
filter.setServerAuthenticationConverter(authConverter);
|
||||
filter.setSecurityContextRepository(securityContextRepository);
|
||||
}
|
||||
|
||||
|
@ -130,121 +107,4 @@ public class UsernamePasswordAuthenticator implements AdditionalWebFilter {
|
|||
return manager;
|
||||
}
|
||||
|
||||
private <T> RateLimiterOperator<T> createIPBasedRateLimiter(ServerWebExchange exchange) {
|
||||
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
|
||||
var rateLimiter =
|
||||
rateLimiterRegistry.rateLimiter("authentication-from-ip-" + clientIp,
|
||||
"authentication");
|
||||
if (log.isDebugEnabled()) {
|
||||
var metrics = rateLimiter.getMetrics();
|
||||
log.debug(
|
||||
"Authentication with Rate Limiter: {}, available permissions: {}, number of "
|
||||
+ "waiting threads: {}",
|
||||
rateLimiter, metrics.getAvailablePermissions(),
|
||||
metrics.getNumberOfWaitingThreads());
|
||||
}
|
||||
return RateLimiterOperator.of(rateLimiter);
|
||||
}
|
||||
|
||||
private Mono<Void> handleRateLimitExceededException(RateLimitExceededException e,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(e, null, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> handleAuthenticationException(AuthenticationException exception,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> writeErrorResponse(ErrorResponse errorResponse,
|
||||
ServerWebExchange exchange) {
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
}
|
||||
|
||||
private class UsernamePasswordAuthenticationWebFilter extends AuthenticationWebFilter {
|
||||
|
||||
public UsernamePasswordAuthenticationWebFilter(
|
||||
ReactiveAuthenticationManager authenticationManager) {
|
||||
super(authenticationManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<Void> onAuthenticationSuccess(Authentication authentication,
|
||||
WebFilterExchange webFilterExchange) {
|
||||
return super.onAuthenticationSuccess(authentication, webFilterExchange)
|
||||
.transformDeferred(createIPBasedRateLimiter(webFilterExchange.getExchange()))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
|
||||
.onErrorResume(RateLimitExceededException.class,
|
||||
e -> handleRateLimitExceededException(e, webFilterExchange.getExchange()));
|
||||
}
|
||||
}
|
||||
|
||||
public class LoginSuccessHandler implements ServerAuthenticationSuccessHandler {
|
||||
|
||||
private final ServerAuthenticationSuccessHandler defaultHandler =
|
||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||
Authentication authentication) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(
|
||||
defaultHandler.onAuthenticationSuccess(webFilterExchange, authentication)
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> {
|
||||
var principal = authentication.getPrincipal();
|
||||
if (principal instanceof CredentialsContainer credentialsContainer) {
|
||||
credentialsContainer.eraseCredentials();
|
||||
}
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(principal)
|
||||
.flatMap(serverResponse ->
|
||||
serverResponse.writeTo(exchange, context));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles login failure.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class LoginFailureHandler implements ServerAuthenticationFailureHandler {
|
||||
|
||||
private final ServerAuthenticationFailureHandler defaultHandler =
|
||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||
|
||||
public LoginFailureHandler() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(defaultHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange))
|
||||
.transformDeferred(createIPBasedRateLimiter(exchange))
|
||||
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
|
||||
.onErrorResume(RateLimitExceededException.class,
|
||||
e -> handleRateLimitExceededException(e, exchange));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +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.authentication.twofactor.TwoFactorAuthentication;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorUtils;
|
||||
|
||||
@Slf4j
|
||||
public class UsernamePasswordDelegatingAuthenticationManager
|
||||
|
@ -38,6 +40,17 @@ public class UsernamePasswordDelegatingAuthenticationManager
|
|||
)
|
||||
.switchIfEmpty(
|
||||
Mono.defer(() -> defaultAuthenticationManager.authenticate(authentication))
|
||||
);
|
||||
)
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static run.halo.app.infra.exception.Exceptions.createErrorResponse;
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.CredentialsContainer;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
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.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
public class UsernamePasswordHandler implements ServerAuthenticationSuccessHandler,
|
||||
ServerAuthenticationFailureHandler {
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final ServerAuthenticationFailureHandler defaultFailureHandler =
|
||||
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
|
||||
|
||||
private final ServerAuthenticationSuccessHandler defaultSuccessHandler =
|
||||
new RedirectServerAuthenticationSuccessHandler("/console/");
|
||||
|
||||
public UsernamePasswordHandler(ServerResponse.Context context, MessageSource messageSource) {
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
|
||||
AuthenticationException exception) {
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return ignoringMediaTypeAll(APPLICATION_JSON)
|
||||
.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(
|
||||
defaultFailureHandler.onAuthenticationFailure(webFilterExchange, exception)
|
||||
// Skip the handleAuthenticationException.
|
||||
.then(Mono.empty())
|
||||
)
|
||||
.flatMap(matchResult -> handleAuthenticationException(exception, exchange));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
|
||||
Authentication authentication) {
|
||||
if (authentication instanceof TwoFactorAuthentication) {
|
||||
// continue filtering for authorization
|
||||
return webFilterExchange.getChain().filter(webFilterExchange.getExchange());
|
||||
}
|
||||
|
||||
ServerWebExchangeMatcher xhrMatcher = exchange -> {
|
||||
if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
|
||||
.contains("XMLHttpRequest")) {
|
||||
return ServerWebExchangeMatcher.MatchResult.match();
|
||||
}
|
||||
return ServerWebExchangeMatcher.MatchResult.notMatch();
|
||||
};
|
||||
|
||||
var exchange = webFilterExchange.getExchange();
|
||||
return xhrMatcher.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> 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));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> handleAuthenticationException(Throwable exception,
|
||||
ServerWebExchange exchange) {
|
||||
var errorResponse = createErrorResponse(exception, UNAUTHORIZED, exchange, messageSource);
|
||||
return writeErrorResponse(errorResponse, exchange);
|
||||
}
|
||||
|
||||
private Mono<Void> writeErrorResponse(ErrorResponse errorResponse,
|
||||
ServerWebExchange exchange) {
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.contentType(APPLICATION_JSON)
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
}
|
||||
|
||||
}
|
|
@ -1,95 +0,0 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.WebFilterExchange;
|
||||
import org.springframework.security.web.server.authentication.logout.LogoutWebFilter;
|
||||
import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler;
|
||||
import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
|
||||
import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
|
||||
/**
|
||||
* Logout handler for username password authentication.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@Component
|
||||
public class UsernamePasswordLogoutHandler implements AdditionalWebFilter {
|
||||
private final ServerSecurityContextRepository securityContextRepository;
|
||||
private final LogoutWebFilter logoutWebFilter;
|
||||
|
||||
/**
|
||||
* Constructs a {@link UsernamePasswordLogoutHandler} with the given
|
||||
* {@link ServerSecurityContextRepository}.
|
||||
* It will create a {@link LogoutWebFilter} instance and configure it.
|
||||
*
|
||||
* @param securityContextRepository a {@link ServerSecurityContextRepository} instance
|
||||
*/
|
||||
public UsernamePasswordLogoutHandler(
|
||||
ServerSecurityContextRepository securityContextRepository) {
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
|
||||
this.logoutWebFilter = new LogoutWebFilter();
|
||||
configureLogoutWebFilter(logoutWebFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||
return logoutWebFilter.filter(exchange, chain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return SecurityWebFiltersOrder.LOGOUT.getOrder();
|
||||
}
|
||||
|
||||
void configureLogoutWebFilter(LogoutWebFilter filter) {
|
||||
var securityContextServerLogoutHandler = new SecurityContextServerLogoutHandler();
|
||||
securityContextServerLogoutHandler.setSecurityContextRepository(securityContextRepository);
|
||||
filter.setLogoutHandler(securityContextServerLogoutHandler);
|
||||
filter.setLogoutSuccessHandler(new LogoutSuccessHandler());
|
||||
}
|
||||
|
||||
/**
|
||||
* Success handler for logout.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public static class LogoutSuccessHandler implements ServerLogoutSuccessHandler {
|
||||
|
||||
private final ServerLogoutSuccessHandler defaultHandler;
|
||||
|
||||
public LogoutSuccessHandler() {
|
||||
var defaultHandler = new RedirectServerLogoutSuccessHandler();
|
||||
defaultHandler.setLogoutSuccessUrl(URI.create("/console/?logout"));
|
||||
this.defaultHandler = defaultHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> onLogoutSuccess(WebFilterExchange exchange,
|
||||
Authentication authentication) {
|
||||
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(exchange.getExchange())
|
||||
.flatMap(matchResult -> {
|
||||
if (matchResult.isMatch()) {
|
||||
exchange.getExchange().getResponse().setStatusCode(HttpStatus.OK);
|
||||
return Mono.empty();
|
||||
}
|
||||
return defaultHandler.onLogoutSuccess(exchange, authentication);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.web.server.DefaultServerRedirectStrategy;
|
||||
import org.springframework.security.web.server.ServerRedirectStrategy;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.exception.Exceptions;
|
||||
|
||||
@Component
|
||||
public class DefaultTwoFactorAuthResponseHandler implements TwoFactorAuthResponseHandler {
|
||||
|
||||
private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
|
||||
|
||||
private static final String REDIRECT_LOCATION = "/console/login/mfa";
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private static final ServerWebExchangeMatcher XHR_MATCHER = exchange -> {
|
||||
if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With")
|
||||
.contains("XMLHttpRequest")) {
|
||||
return ServerWebExchangeMatcher.MatchResult.match();
|
||||
}
|
||||
return ServerWebExchangeMatcher.MatchResult.notMatch();
|
||||
};
|
||||
|
||||
public DefaultTwoFactorAuthResponseHandler(MessageSource messageSource,
|
||||
ServerResponse.Context context) {
|
||||
this.messageSource = messageSource;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> handle(ServerWebExchange exchange) {
|
||||
return XHR_MATCHER.matches(exchange)
|
||||
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||
.switchIfEmpty(Mono.defer(
|
||||
() -> redirectStrategy.sendRedirect(exchange, URI.create(REDIRECT_LOCATION))
|
||||
.then(Mono.empty())))
|
||||
.flatMap(isXhr -> {
|
||||
var errorResponse = Exceptions.createErrorResponse(
|
||||
new TwoFactorAuthRequiredException(URI.create(REDIRECT_LOCATION)),
|
||||
null, exchange, messageSource);
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.bodyValue(errorResponse.getBody())
|
||||
.flatMap(response -> response.writeTo(exchange, context));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,283 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.path;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.net.URI;
|
||||
import lombok.Data;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.Validator;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
import run.halo.app.infra.exception.RequestBodyValidationException;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
|
||||
|
||||
@Component
|
||||
public class TwoFactorAuthEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private final Validator validator;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
private final ExternalUrlSupplier externalUrl;
|
||||
|
||||
public TwoFactorAuthEndpoint(ReactiveExtensionClient client,
|
||||
UserService userService,
|
||||
TotpAuthService totpAuthService,
|
||||
Validator validator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
ExternalUrlSupplier externalUrl) {
|
||||
this.client = client;
|
||||
this.userService = userService;
|
||||
this.totpAuthService = totpAuthService;
|
||||
this.validator = validator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.externalUrl = externalUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
var tag = groupVersion() + "/Authentication/TwoFactor";
|
||||
return route().nest(path("/authentications/two-factor"),
|
||||
() -> route()
|
||||
.GET("/settings", this::getTwoFactorSettings,
|
||||
builder -> builder.operationId("GetTwoFactorAuthenticationSettings")
|
||||
.tag(tag)
|
||||
.description("Get Two-factor authentication settings.")
|
||||
.response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
|
||||
.PUT("/settings/enabled", this::enableTwoFactor,
|
||||
builder -> builder.operationId("EnableTwoFactor")
|
||||
.tag(tag)
|
||||
.description("Enable Two-factor authentication")
|
||||
.requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
|
||||
.response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
|
||||
.PUT("/settings/disabled", this::disableTwoFactor,
|
||||
builder -> builder.operationId("DisableTwoFactor")
|
||||
.tag(tag)
|
||||
.description("Disable Two-factor authentication")
|
||||
.requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
|
||||
.response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
|
||||
.POST("/totp", this::configureTotp,
|
||||
builder -> builder.operationId("ConfigurerTotp")
|
||||
.tag(tag)
|
||||
.description("Configure a TOTP")
|
||||
.requestBody(requestBodyBuilder().implementation(TotpRequest.class))
|
||||
.response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
|
||||
.DELETE("/totp/-", this::deleteTotp,
|
||||
builder -> builder.operationId("DeleteTotp")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder().implementation(PasswordRequest.class))
|
||||
.response(responseBuilder().implementation(TwoFactorAuthSettings.class)))
|
||||
.GET("/totp/auth-link", this::getTotpAuthLink,
|
||||
builder -> builder.operationId("GetTotpAuthLink")
|
||||
.tag(tag)
|
||||
.description("Get TOTP auth link, including secret")
|
||||
.response(responseBuilder().implementation(TotpAuthLinkResponse.class)))
|
||||
.build(),
|
||||
builder -> builder.description("Two-factor authentication endpoint(User-scoped)")
|
||||
).build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteTotp(ServerRequest request) {
|
||||
var totpDeleteRequestMono = request.bodyToMono(PasswordRequest.class)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required")))
|
||||
.doOnNext(
|
||||
passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest"));
|
||||
|
||||
var twoFactorAuthSettings =
|
||||
totpDeleteRequestMono.flatMap(passwordRequest -> getCurrentUser()
|
||||
.filter(user -> {
|
||||
var rawPassword = passwordRequest.getPassword();
|
||||
var encodedPassword = user.getSpec().getPassword();
|
||||
return this.passwordEncoder.matches(rawPassword, encodedPassword);
|
||||
})
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new ServerWebInputException("Invalid password")))
|
||||
.doOnNext(user -> {
|
||||
var spec = user.getSpec();
|
||||
spec.setTotpEncryptedSecret(null);
|
||||
})
|
||||
.flatMap(client::update)
|
||||
.map(TwoFactorUtils::getTwoFactorAuthSettings));
|
||||
return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PasswordRequest {
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> disableTwoFactor(ServerRequest request) {
|
||||
return toggleTwoFactor(request, false);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> enableTwoFactor(ServerRequest request) {
|
||||
return toggleTwoFactor(request, true);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> toggleTwoFactor(ServerRequest request, boolean enabled) {
|
||||
return request.bodyToMono(PasswordRequest.class)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required")))
|
||||
.doOnNext(passwordRequest -> this.validateRequest(passwordRequest, "passwordRequest"))
|
||||
.flatMap(passwordRequest -> getCurrentUser()
|
||||
.filter(user -> {
|
||||
var encodedPassword = user.getSpec().getPassword();
|
||||
var rawPassword = passwordRequest.getPassword();
|
||||
return passwordEncoder.matches(rawPassword, encodedPassword);
|
||||
})
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new ServerWebInputException("Invalid password")))
|
||||
.doOnNext(user -> user.getSpec().setTwoFactorAuthEnabled(enabled))
|
||||
.flatMap(client::update)
|
||||
.map(TwoFactorUtils::getTwoFactorAuthSettings))
|
||||
.flatMap(twoFactorAuthSettings -> ServerResponse.ok().bodyValue(twoFactorAuthSettings));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getTotpAuthLink(ServerRequest request) {
|
||||
var authLinkResponse = getCurrentUser()
|
||||
.map(user -> {
|
||||
var username = user.getMetadata().getName();
|
||||
var url = externalUrl.getURL(request.exchange().getRequest());
|
||||
var authority = url.getAuthority();
|
||||
var authKeyId = username + ":" + authority;
|
||||
var rawSecret = totpAuthService.generateTotpSecret();
|
||||
var authLink = UriComponentsBuilder.fromUriString("otpauth://totp")
|
||||
.path(authKeyId)
|
||||
.queryParam("secret", rawSecret)
|
||||
.queryParam("digits", 6)
|
||||
.build().toUri();
|
||||
var authLinkResp = new TotpAuthLinkResponse();
|
||||
authLinkResp.setAuthLink(authLink);
|
||||
authLinkResp.setRawSecret(rawSecret);
|
||||
return authLinkResp;
|
||||
});
|
||||
|
||||
return ServerResponse.ok().body(authLinkResponse, TotpAuthLinkResponse.class);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TotpAuthLinkResponse {
|
||||
|
||||
/**
|
||||
* QR Code with base64 encoded.
|
||||
*/
|
||||
private URI authLink;
|
||||
|
||||
private String rawSecret;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> configureTotp(ServerRequest request) {
|
||||
var totpRequestMono = request.bodyToMono(TotpRequest.class)
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body required.")))
|
||||
.doOnNext(totpRequest -> this.validateRequest(totpRequest, "totp"));
|
||||
|
||||
var configuredUser = totpRequestMono.flatMap(totpRequest -> {
|
||||
// validate password
|
||||
return getCurrentUser()
|
||||
.filter(user -> {
|
||||
var encodedPassword = user.getSpec().getPassword();
|
||||
var rawPassword = totpRequest.getPassword();
|
||||
return passwordEncoder.matches(rawPassword, encodedPassword);
|
||||
})
|
||||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Invalid password")))
|
||||
.doOnNext(user -> {
|
||||
// TimeBasedOneTimePasswordUtil.
|
||||
var rawSecret = totpRequest.getSecret();
|
||||
int code;
|
||||
try {
|
||||
code = Integer.parseInt(totpRequest.getCode());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new ServerWebInputException("Invalid code");
|
||||
}
|
||||
var validated = totpAuthService.validateTotp(rawSecret, code);
|
||||
if (!validated) {
|
||||
throw new ServerWebInputException("Invalid secret or code");
|
||||
}
|
||||
var encryptedSecret = totpAuthService.encryptSecret(rawSecret);
|
||||
user.getSpec().setTotpEncryptedSecret(encryptedSecret);
|
||||
})
|
||||
.flatMap(client::update);
|
||||
});
|
||||
|
||||
var twoFactorAuthSettings =
|
||||
configuredUser.map(TwoFactorUtils::getTwoFactorAuthSettings);
|
||||
|
||||
return ServerResponse.ok().body(twoFactorAuthSettings, TwoFactorAuthSettings.class);
|
||||
}
|
||||
|
||||
private void validateRequest(Object target, String name) {
|
||||
var errors = new BeanPropertyBindingResult(target, name);
|
||||
validator.validate(target, errors);
|
||||
if (errors.hasErrors()) {
|
||||
throw new RequestBodyValidationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TotpRequest {
|
||||
|
||||
@NotBlank
|
||||
private String secret;
|
||||
|
||||
@NotNull
|
||||
private String code;
|
||||
|
||||
@NotBlank
|
||||
private String password;
|
||||
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getTwoFactorSettings(ServerRequest request) {
|
||||
return getCurrentUser()
|
||||
.map(TwoFactorUtils::getTwoFactorAuthSettings)
|
||||
.flatMap(settings -> ServerResponse.ok().bodyValue(settings));
|
||||
}
|
||||
|
||||
private Mono<User> getCurrentUser() {
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.filter(TwoFactorAuthEndpoint::isAuthenticatedUser)
|
||||
.switchIfEmpty(Mono.error(AccessDeniedException::new))
|
||||
.map(Authentication::getName)
|
||||
.flatMap(userService::getUser);
|
||||
}
|
||||
|
||||
private static boolean isAuthenticatedUser(Authentication authentication) {
|
||||
return authentication != null && !(authentication instanceof AnonymousAuthenticationToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("api.security.halo.run/v1alpha1");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
public class TwoFactorAuthRequiredException extends ResponseStatusException {
|
||||
|
||||
private static final URI type = URI.create("https://halo.run/probs/2fa-required");
|
||||
|
||||
public TwoFactorAuthRequiredException(URI redirectURI) {
|
||||
super(HttpStatus.UNAUTHORIZED, "Two-factor authentication required");
|
||||
setType(type);
|
||||
getBody().setProperty("redirectURI", redirectURI);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface TwoFactorAuthResponseHandler {
|
||||
|
||||
Mono<Void> handle(ServerWebExchange exchange);
|
||||
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthService;
|
||||
import run.halo.app.security.authentication.twofactor.totp.TotpAuthenticationFilter;
|
||||
|
||||
@Component
|
||||
public class TwoFactorAuthSecurityConfigurer implements SecurityConfigurer {
|
||||
|
||||
private final ServerSecurityContextRepository securityContextRepository;
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private final ServerResponse.Context context;
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public TwoFactorAuthSecurityConfigurer(
|
||||
ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService, ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
this.securityContextRepository = securityContextRepository;
|
||||
this.totpAuthService = totpAuthService;
|
||||
this.context = context;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(ServerHttpSecurity http) {
|
||||
var filter = new TotpAuthenticationFilter(securityContextRepository, totpAuthService,
|
||||
context, messageSource);
|
||||
http.addFilterAfter(filter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class TwoFactorAuthSettings {
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
private boolean emailVerified;
|
||||
|
||||
private boolean totpConfigured;
|
||||
|
||||
/**
|
||||
* Check if 2FA is available.
|
||||
*
|
||||
* @return true if 2FA is enabled and configured, false otherwise.
|
||||
*/
|
||||
public boolean isAvailable() {
|
||||
return enabled && (emailVerified || totpConfigured);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
public class TwoFactorAuthentication extends AbstractAuthenticationToken {
|
||||
|
||||
private final Authentication previous;
|
||||
|
||||
/**
|
||||
* Creates a token with the supplied array of authorities.
|
||||
*
|
||||
* @param previous the previous authentication
|
||||
*/
|
||||
public TwoFactorAuthentication(Authentication previous) {
|
||||
super(List.of(new SimpleGrantedAuthority(ANONYMOUS_ROLE_NAME)));
|
||||
this.previous = previous;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return previous.getCredentials();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return previous.getPrincipal();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public Authentication getPrevious() {
|
||||
return previous;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.ReactiveAuthorizationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class TwoFactorAuthorizationManager
|
||||
implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
|
||||
private final ReactiveAuthorizationManager<AuthorizationContext> delegate;
|
||||
|
||||
private static final URI REDIRECT_LOCATION = URI.create("/console/login?2fa=totp");
|
||||
|
||||
public TwoFactorAuthorizationManager(
|
||||
ReactiveAuthorizationManager<AuthorizationContext> delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
|
||||
AuthorizationContext context) {
|
||||
return authentication.flatMap(a -> {
|
||||
Mono<AuthorizationDecision> checked = delegate.check(Mono.just(a), context);
|
||||
if (a instanceof TwoFactorAuthentication) {
|
||||
checked = checked.filter(AuthorizationDecision::isGranted)
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new TwoFactorAuthRequiredException(REDIRECT_LOCATION)));
|
||||
}
|
||||
return checked;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package run.halo.app.security.authentication.twofactor;
|
||||
|
||||
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import run.halo.app.core.extension.User;
|
||||
|
||||
public enum TwoFactorUtils {
|
||||
;
|
||||
|
||||
public static TwoFactorAuthSettings getTwoFactorAuthSettings(User user) {
|
||||
var spec = user.getSpec();
|
||||
var tfaEnabled = defaultIfNull(spec.getTwoFactorAuthEnabled(), false);
|
||||
var emailVerified = spec.isEmailVerified();
|
||||
var totpEncryptedSecret = spec.getTotpEncryptedSecret();
|
||||
var settings = new TwoFactorAuthSettings();
|
||||
settings.setEnabled(tfaEnabled);
|
||||
settings.setEmailVerified(emailVerified);
|
||||
settings.setTotpConfigured(StringUtils.isNotBlank(totpEncryptedSecret));
|
||||
return settings;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.generateBase32Secret;
|
||||
import static com.j256.twofactorauth.TimeBasedOneTimePasswordUtil.validateCurrentNumber;
|
||||
import static java.nio.file.StandardOpenOption.APPEND;
|
||||
import static java.nio.file.StandardOpenOption.CREATE;
|
||||
import static java.nio.file.StandardOpenOption.READ;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableEntryException;
|
||||
import java.security.cert.CertificateException;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor;
|
||||
import org.springframework.security.crypto.encrypt.BytesEncryptor;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DefaultTotpAuthService implements TotpAuthService {
|
||||
|
||||
private final BytesEncryptor encryptor;
|
||||
|
||||
public DefaultTotpAuthService(HaloProperties haloProperties) {
|
||||
// init secret key
|
||||
var keysRoot = haloProperties.getWorkDir().resolve("keys");
|
||||
this.encryptor = loadOrCreateEncryptor(keysRoot);
|
||||
}
|
||||
|
||||
private BytesEncryptor loadOrCreateEncryptor(Path keysRoot) {
|
||||
try {
|
||||
if (Files.notExists(keysRoot)) {
|
||||
Files.createDirectories(keysRoot);
|
||||
}
|
||||
var keyStorePath = keysRoot.resolve("halo.keystore");
|
||||
var password = "changeit".toCharArray();
|
||||
var keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
if (Files.notExists(keyStorePath)) {
|
||||
keyStore.load(null, password);
|
||||
} else {
|
||||
try (var is = Files.newInputStream(keyStorePath, READ)) {
|
||||
keyStore.load(is, password);
|
||||
}
|
||||
}
|
||||
|
||||
var alias = "totp-secret-key";
|
||||
var entry = keyStore.getEntry(alias, new KeyStore.PasswordProtection(password));
|
||||
SecretKey secretKey = null;
|
||||
if (entry instanceof KeyStore.SecretKeyEntry secretKeyEntry) {
|
||||
if ("AES".equalsIgnoreCase(secretKeyEntry.getSecretKey().getAlgorithm())) {
|
||||
secretKey = secretKeyEntry.getSecretKey();
|
||||
}
|
||||
}
|
||||
if (secretKey == null) {
|
||||
var generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(128);
|
||||
secretKey = generator.generateKey();
|
||||
var secretKeyEntry = new KeyStore.SecretKeyEntry(secretKey);
|
||||
keyStore.setEntry(alias, secretKeyEntry, new KeyStore.PasswordProtection(password));
|
||||
try (var os = Files.newOutputStream(keyStorePath, CREATE, APPEND)) {
|
||||
keyStore.store(os, password);
|
||||
}
|
||||
}
|
||||
return new AesBytesEncryptor(secretKey,
|
||||
KeyGenerators.secureRandom(32),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
} catch (IOException | KeyStoreException | CertificateException | NoSuchAlgorithmException
|
||||
| UnrecoverableEntryException e) {
|
||||
throw new RuntimeException("Failed to initialize AesBytesEncryptor", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean validateTotp(String rawSecret, int code) {
|
||||
try {
|
||||
return validateCurrentNumber(rawSecret, code, 10 * 1000);
|
||||
} catch (GeneralSecurityException e) {
|
||||
log.warn("Error occurred when validate TOTP code", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateTotpSecret() {
|
||||
return generateBase32Secret(32);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String encryptSecret(String rawSecret) {
|
||||
return new String(Hex.encode(encryptor.encrypt(rawSecret.getBytes())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String decryptSecret(String encryptedSecret) {
|
||||
return new String(encryptor.decrypt(Hex.decode(encryptedSecret)));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
public interface TotpAuthService {
|
||||
|
||||
boolean validateTotp(String rawSecret, int code);
|
||||
|
||||
String generateTotpSecret();
|
||||
|
||||
String encryptSecret(String rawSecret);
|
||||
|
||||
String decryptSecret(String encryptedSecret);
|
||||
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.MessageSource;
|
||||
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.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
|
||||
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
|
||||
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||
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.authentication.login.UsernamePasswordHandler;
|
||||
import run.halo.app.security.authentication.twofactor.TwoFactorAuthentication;
|
||||
|
||||
@Slf4j
|
||||
public class TotpAuthenticationFilter extends AuthenticationWebFilter {
|
||||
|
||||
public TotpAuthenticationFilter(ServerSecurityContextRepository securityContextRepository,
|
||||
TotpAuthService totpAuthService,
|
||||
ServerResponse.Context context,
|
||||
MessageSource messageSource) {
|
||||
super(new TwoFactorAuthManager(totpAuthService));
|
||||
|
||||
setSecurityContextRepository(securityContextRepository);
|
||||
setRequiresAuthenticationMatcher(pathMatchers(HttpMethod.POST, "/login/2fa/totp"));
|
||||
setServerAuthenticationConverter(new TotpCodeAuthenticationConverter());
|
||||
|
||||
var handler = new UsernamePasswordHandler(context, messageSource);
|
||||
setAuthenticationSuccessHandler(handler);
|
||||
setAuthenticationFailureHandler(handler);
|
||||
}
|
||||
|
||||
private static class TotpCodeAuthenticationConverter implements ServerAuthenticationConverter {
|
||||
|
||||
private final String codeParameter = "code";
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> convert(ServerWebExchange exchange) {
|
||||
// Check the request is authenticated before.
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.filter(TwoFactorAuthentication.class::isInstance)
|
||||
.switchIfEmpty(Mono.error(
|
||||
() -> new TwoFactorAuthException("MFA Authentication required.")))
|
||||
.flatMap(authentication -> exchange.getFormData())
|
||||
.handle((formData, sink) -> {
|
||||
var codeStr = formData.getFirst(codeParameter);
|
||||
if (StringUtils.isBlank(codeStr)) {
|
||||
sink.error(new TwoFactorAuthException("Empty code parameter."));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var code = Integer.parseInt(codeStr);
|
||||
sink.next(new TotpAuthenticationToken(code));
|
||||
} catch (NumberFormatException e) {
|
||||
sink.error(
|
||||
new TwoFactorAuthException("Invalid code parameter " + codeStr + '.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static class TwoFactorAuthException extends AuthenticationException {
|
||||
|
||||
public TwoFactorAuthException(String msg, Throwable cause) {
|
||||
super(msg, cause);
|
||||
}
|
||||
|
||||
public TwoFactorAuthException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class TwoFactorAuthManager implements ReactiveAuthenticationManager {
|
||||
|
||||
private final TotpAuthService totpAuthService;
|
||||
|
||||
private TwoFactorAuthManager(TotpAuthService totpAuthService) {
|
||||
this.totpAuthService = totpAuthService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Authentication> authenticate(Authentication authentication) {
|
||||
// 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;
|
||||
}
|
||||
var encryptedSecret =
|
||||
haloUser.getDelegate().getSpec().getTotpEncryptedSecret();
|
||||
if (StringUtils.isBlank(encryptedSecret)) {
|
||||
sink.error(new TwoFactorAuthException("Empty secret configured"));
|
||||
return;
|
||||
}
|
||||
var rawSecret = totpAuthService.decryptSecret(encryptedSecret);
|
||||
var validated = totpAuthService.validateTotp(rawSecret, code);
|
||||
if (!validated) {
|
||||
sink.error(new TwoFactorAuthException("Invalid TOTP code " + code));
|
||||
return;
|
||||
}
|
||||
sink.next(prevAuth);
|
||||
})
|
||||
.doOnNext(previousAuth -> {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("TOTP authentication for {} with code {} successfully.",
|
||||
previousAuth.getName(), code);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
package run.halo.app.security.authentication.twofactor.totp;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
|
||||
public class TotpAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final int code;
|
||||
|
||||
public TotpAuthenticationToken(int code) {
|
||||
super(List.of());
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return getCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAuthenticated() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.security.authorization;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* Attributes is used by an Authorizer to get information about a request
|
||||
|
@ -13,7 +13,7 @@ public interface Attributes {
|
|||
/**
|
||||
* @return the UserDetails object to authorize
|
||||
*/
|
||||
UserDetails getUser();
|
||||
Principal getPrincipal();
|
||||
|
||||
/**
|
||||
* @return the verb associated with API requests(this includes get, list,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.security.authorization;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import java.security.Principal;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
|
@ -8,16 +8,16 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||
*/
|
||||
public class AttributesRecord implements Attributes {
|
||||
private final RequestInfo requestInfo;
|
||||
private final UserDetails user;
|
||||
private final Principal principal;
|
||||
|
||||
public AttributesRecord(UserDetails user, RequestInfo requestInfo) {
|
||||
public AttributesRecord(Principal principal, RequestInfo requestInfo) {
|
||||
this.requestInfo = requestInfo;
|
||||
this.user = user;
|
||||
this.principal = principal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails getUser() {
|
||||
return this.user;
|
||||
public Principal getPrincipal() {
|
||||
return this.principal;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -20,6 +20,10 @@ public enum AuthorityUtils {
|
|||
|
||||
public static final String SUPER_ROLE_NAME = "super-role";
|
||||
|
||||
public static final String AUTHENTICATED_ROLE_NAME = "authenticated";
|
||||
|
||||
public static final String ANONYMOUS_ROLE_NAME = "anonymous";
|
||||
|
||||
/**
|
||||
* Converts an array of GrantedAuthority objects to a role set.
|
||||
*
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package run.halo.app.security.authorization;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
|
@ -9,5 +9,5 @@ import reactor.core.publisher.Mono;
|
|||
*/
|
||||
public interface AuthorizationRuleResolver {
|
||||
|
||||
Mono<AuthorizingVisitor> visitRules(UserDetails user, RequestInfo requestInfo);
|
||||
Mono<AuthorizingVisitor> visitRules(Authentication authentication, RequestInfo requestInfo);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
package run.halo.app.security.authorization;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.service.DefaultRoleBindingService;
|
||||
import run.halo.app.core.extension.service.RoleBindingService;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.infra.AnonymousUserConst;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
|
@ -21,32 +20,24 @@ import run.halo.app.infra.AnonymousUserConst;
|
|||
@Data
|
||||
@Slf4j
|
||||
public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
||||
private static final String AUTHENTICATED_ROLE = "authenticated";
|
||||
private RoleService roleService;
|
||||
|
||||
private RoleBindingService roleBindingService;
|
||||
private RoleService roleService;
|
||||
|
||||
public DefaultRuleResolver(RoleService roleService) {
|
||||
this.roleService = roleService;
|
||||
this.roleBindingService = new DefaultRoleBindingService();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<AuthorizingVisitor> visitRules(UserDetails user, RequestInfo requestInfo) {
|
||||
var roleNamesImmutable = roleBindingService.listBoundRoleNames(user.getAuthorities());
|
||||
var roleNames = new HashSet<>(roleNamesImmutable);
|
||||
if (!AnonymousUserConst.PRINCIPAL.equals(user.getUsername())) {
|
||||
roleNames.add(AUTHENTICATED_ROLE);
|
||||
roleNames.add(AnonymousUserConst.Role);
|
||||
}
|
||||
|
||||
var record = new AttributesRecord(user, requestInfo);
|
||||
public Mono<AuthorizingVisitor> visitRules(Authentication authentication,
|
||||
RequestInfo requestInfo) {
|
||||
var roleNames = listBoundRoleNames(authentication.getAuthorities());
|
||||
var record = new AttributesRecord(authentication, requestInfo);
|
||||
var visitor = new AuthorizingVisitor(record);
|
||||
|
||||
// If the request is an userspace scoped request,
|
||||
// then we should check whether the user is the owner of the userspace.
|
||||
if (StringUtils.isNotBlank(requestInfo.getUserspace())) {
|
||||
if (!user.getUsername().equals(requestInfo.getUserspace())) {
|
||||
if (!authentication.getName().equals(requestInfo.getUserspace())) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
visitor.visit(null, null, null);
|
||||
return visitor;
|
||||
|
@ -63,7 +54,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
}
|
||||
String roleName = role.getMetadata().getName();
|
||||
var rules = role.getRules();
|
||||
var source = roleBindingDescriber(roleName, user.getUsername());
|
||||
var source = roleBindingDescriber(roleName, authentication.getName());
|
||||
for (var rule : rules) {
|
||||
if (!visitor.visit(source, rule, null)) {
|
||||
stopVisiting.set(true);
|
||||
|
@ -73,7 +64,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
})
|
||||
.takeUntil(item -> stopVisiting.get())
|
||||
.onErrorResume(t -> visitor.visit(null, null, t), t -> {
|
||||
log.warn("Error occurred when visiting rules", t);
|
||||
log.error("Error occurred when visiting rules", t);
|
||||
//Do nothing here
|
||||
return Mono.empty();
|
||||
})
|
||||
|
@ -84,8 +75,15 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
|
|||
return String.format("Binding role [%s] to [%s]", roleName, subject);
|
||||
}
|
||||
|
||||
public void setRoleBindingService(RoleBindingService roleBindingService) {
|
||||
Assert.notNull(roleBindingService, "The roleBindingLister must not be null.");
|
||||
this.roleBindingService = roleBindingService;
|
||||
private static Set<String> listBoundRoleNames(
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
return authorities.stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.map(authority -> {
|
||||
authority = StringUtils.removeStart(authority, AuthorityUtils.SCOPE_PREFIX);
|
||||
authority = StringUtils.removeStart(authority, AuthorityUtils.ROLE_PREFIX);
|
||||
return authority;
|
||||
})
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,17 +6,13 @@ import org.springframework.http.server.reactive.ServerHttpRequest;
|
|||
import org.springframework.security.authorization.AuthorizationDecision;
|
||||
import org.springframework.security.authorization.ReactiveAuthorizationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
|
||||
@Slf4j
|
||||
public class RequestInfoAuthorizationManager
|
||||
implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||
|
||||
private final AuthorizationRuleResolver ruleResolver;
|
||||
|
||||
|
@ -26,41 +22,24 @@ public class RequestInfoAuthorizationManager
|
|||
|
||||
@Override
|
||||
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
|
||||
AuthorizationContext context) {
|
||||
AuthorizationContext context) {
|
||||
ServerHttpRequest request = context.getExchange().getRequest();
|
||||
RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
|
||||
|
||||
return authentication.flatMap(auth -> {
|
||||
var userDetails = this.createUserDetails(auth);
|
||||
return this.ruleResolver.visitRules(userDetails, requestInfo)
|
||||
.map(visitor -> {
|
||||
if (!visitor.isAllowed()) {
|
||||
showErrorMessage(visitor.getErrors());
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
return new AuthorizationDecision(isGranted(auth));
|
||||
});
|
||||
});
|
||||
return authentication.flatMap(auth -> this.ruleResolver.visitRules(auth, requestInfo)
|
||||
.doOnNext(visitor -> showErrorMessage(visitor.getErrors()))
|
||||
.filter(AuthorizingVisitor::isAllowed)
|
||||
.map(visitor -> new AuthorizationDecision(isGranted(auth)))
|
||||
.switchIfEmpty(Mono.fromSupplier(() -> new AuthorizationDecision(false))));
|
||||
}
|
||||
|
||||
private boolean isGranted(Authentication authentication) {
|
||||
return authentication != null && authentication.isAuthenticated();
|
||||
}
|
||||
|
||||
private UserDetails createUserDetails(Authentication authentication) {
|
||||
Assert.notNull(authentication, "The authentication must not be null.");
|
||||
return User.withUsername(authentication.getName())
|
||||
.authorities(authentication.getAuthorities())
|
||||
.password("")
|
||||
.build();
|
||||
}
|
||||
|
||||
private void showErrorMessage(List<Throwable> errors) {
|
||||
if (CollectionUtils.isEmpty(errors)) {
|
||||
return;
|
||||
}
|
||||
for (Throwable error : errors) {
|
||||
log.error("Access decision error: ", error);
|
||||
if (errors != null) {
|
||||
errors.forEach(error -> log.error("Access decision error", error));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ metadata:
|
|||
"role-template-stats",
|
||||
"role-template-annotation-setting",
|
||||
"role-template-manage-own-pat",
|
||||
"role-template-manage-own-authentications",
|
||||
"role-template-user-notification"
|
||||
]
|
||||
rules:
|
||||
|
@ -115,6 +116,19 @@ rules:
|
|||
- apiGroups: [ "api.security.halo.run" ]
|
||||
resources: [ "personalaccesstokens/actions" ]
|
||||
verbs: [ "update" ]
|
||||
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: "Role"
|
||||
metadata:
|
||||
name: role-template-manage-own-authentications
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true"
|
||||
rules:
|
||||
- apiGroups: [ "api.security.halo.run" ]
|
||||
resources: [ "authentications", "authentications/totp" ]
|
||||
verbs: [ "*" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
|
|
|
@ -532,28 +532,7 @@ class UserEndpointTest {
|
|||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.json("""
|
||||
{
|
||||
"spec":{
|
||||
"displayName":"Faker",
|
||||
"avatar":"fake-avatar.png",
|
||||
"email":"hi@halo.run",
|
||||
"password":"fake-password",
|
||||
"bio":"Fake bio"
|
||||
},
|
||||
"status":null,
|
||||
"apiVersion":"v1alpha1",
|
||||
"kind":"User",
|
||||
"metadata":{
|
||||
"name":"fake-user",
|
||||
"annotations":{
|
||||
"halo.run/avatar-attachment-name":
|
||||
"fake-attachment"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
.expectBody(User.class).isEqualTo(currentUser);
|
||||
|
||||
verify(client).get(User.class, "fake-user");
|
||||
verify(client).update(currentUser);
|
||||
|
|
|
@ -5,16 +5,15 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.core.authority.AuthorityUtils.authorityListToSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
@ -101,11 +100,9 @@ class DefaultUserDetailServiceTest {
|
|||
.assertNext(gotUser -> {
|
||||
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
|
||||
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
|
||||
assertEquals(List.of("ROLE_fake-role"),
|
||||
gotUser.getAuthorities()
|
||||
.stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.collect(Collectors.toList()));
|
||||
assertEquals(
|
||||
Set.of("ROLE_fake-role", "ROLE_authenticated", "ROLE_anonymous"),
|
||||
authorityListToSet(gotUser.getAuthorities()));
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
@ -133,7 +130,11 @@ class DefaultUserDetailServiceTest {
|
|||
.assertNext(gotUser -> {
|
||||
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
|
||||
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
|
||||
assertEquals(0, gotUser.getAuthorities().size());
|
||||
assertEquals(2, gotUser.getAuthorities().size());
|
||||
assertEquals(
|
||||
Set.of("ROLE_anonymous", "ROLE_authenticated"),
|
||||
authorityListToSet(gotUser.getAuthorities())
|
||||
);
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
@ -155,7 +156,9 @@ class DefaultUserDetailServiceTest {
|
|||
.assertNext(gotUser -> {
|
||||
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
|
||||
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
|
||||
assertEquals(0, gotUser.getAuthorities().size());
|
||||
assertEquals(
|
||||
Set.of("ROLE_anonymous", "ROLE_authenticated"),
|
||||
authorityListToSet(gotUser.getAuthorities()));
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
package run.halo.app.security.authentication.login;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -12,12 +18,16 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoginAuthenticationConverterTest {
|
||||
|
@ -25,18 +35,51 @@ class LoginAuthenticationConverterTest {
|
|||
@Mock
|
||||
ServerWebExchange exchange;
|
||||
|
||||
MultiValueMap<String, String> formData;
|
||||
@Mock
|
||||
CryptoService cryptoService;
|
||||
|
||||
@Mock
|
||||
RateLimiterRegistry rateLimiterRegistry;
|
||||
|
||||
@InjectMocks
|
||||
LoginAuthenticationConverter converter;
|
||||
|
||||
@Mock
|
||||
CryptoService cryptoService;
|
||||
MultiValueMap<String, String> formData;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
formData = new LinkedMultiValueMap<>();
|
||||
lenient().when(exchange.getFormData()).thenReturn(Mono.just(formData));
|
||||
var request = mock(ServerHttpRequest.class);
|
||||
var headers = new HttpHeaders();
|
||||
|
||||
when(request.getHeaders()).thenReturn(headers);
|
||||
when(exchange.getRequest()).thenReturn(request);
|
||||
when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown",
|
||||
"authentication"))
|
||||
.thenReturn(RateLimiter.ofDefaults("authentication"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTriggerRateLimit() {
|
||||
var username = "username";
|
||||
var password = "password";
|
||||
|
||||
formData.add("username", username);
|
||||
formData.add("password", Base64.getEncoder().encodeToString(password.getBytes()));
|
||||
var rateLimiter = RateLimiter.of("authentication", RateLimiterConfig.custom()
|
||||
.limitForPeriod(1)
|
||||
.limitRefreshPeriod(Duration.ofSeconds(1))
|
||||
.timeoutDuration(Duration.ofMillis(0))
|
||||
.build());
|
||||
assertTrue(rateLimiter.acquirePermission(1));
|
||||
when(rateLimiterRegistry.rateLimiter("authentication-from-ip-unknown", "authentication"))
|
||||
.thenReturn(rateLimiter);
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.expectError(RateLimitExceededException.class)
|
||||
.verify();
|
||||
|
||||
verify(cryptoService, never()).decrypt(password.getBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -51,10 +94,7 @@ class LoginAuthenticationConverterTest {
|
|||
when(cryptoService.decrypt(password.getBytes()))
|
||||
.thenReturn(Mono.just(decryptedPassword.getBytes()));
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.assertNext(token -> {
|
||||
assertEquals(username, token.getPrincipal());
|
||||
assertEquals(decryptedPassword, token.getCredentials());
|
||||
})
|
||||
.expectNext(new UsernamePasswordAuthenticationToken(username, decryptedPassword))
|
||||
.verifyComplete();
|
||||
|
||||
verify(cryptoService).decrypt(password.getBytes());
|
||||
|
@ -83,7 +123,7 @@ class LoginAuthenticationConverterTest {
|
|||
when(cryptoService.decrypt(password.getBytes()))
|
||||
.thenReturn(Mono.error(() -> new InvalidEncryptedMessageException("invalid message")));
|
||||
StepVerifier.create(converter.convert(exchange))
|
||||
.verifyError(BadCredentialsException.class);
|
||||
.verifyError(BadCredentialsException.class);
|
||||
verify(cryptoService).decrypt(password.getBytes());
|
||||
}
|
||||
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
package run.halo.app.security.authorization;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import run.halo.app.core.extension.service.DefaultRoleBindingService;
|
||||
|
||||
/**
|
||||
* Tests for {@link DefaultRoleBindingService}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class DefaultRoleBindingServiceTest {
|
||||
|
||||
private DefaultRoleBindingService roleBindingLister;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
roleBindingLister = new DefaultRoleBindingService();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listWhenAuthorizedRoles() {
|
||||
var authorities = List.of(
|
||||
new SimpleGrantedAuthority("readFake"),
|
||||
new SimpleGrantedAuthority("fake.read"),
|
||||
new SimpleGrantedAuthority("ROLE_role.fake.read"),
|
||||
new SimpleGrantedAuthority("SCOPE_scope.fake.read"),
|
||||
new SimpleGrantedAuthority("SCOPE_ROLE_scope.role.fake.read"));
|
||||
|
||||
Set<String> roleBindings = roleBindingLister.listBoundRoleNames(authorities);
|
||||
assertThat(roleBindings).isNotNull();
|
||||
assertThat(roleBindings).isEqualTo(Set.of(
|
||||
"readFake",
|
||||
"fake.read",
|
||||
"role.fake.read",
|
||||
"scope.fake.read",
|
||||
"scope.role.fake.read"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listWhenUnauthorizedThenEmpty() {
|
||||
var roleBindings = roleBindingLister.listBoundRoleNames(Collections.emptyList());
|
||||
assertThat(roleBindings).isEmpty();
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import static org.mockito.Mockito.times;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method;
|
||||
import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.authenticated;
|
||||
import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList;
|
||||
|
||||
import java.util.List;
|
||||
|
@ -34,29 +35,33 @@ class DefaultRuleResolverTest {
|
|||
|
||||
@Test
|
||||
void visitRules() {
|
||||
when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost")))
|
||||
when(roleService.listDependenciesFlux(Set.of("ruleReadPost")))
|
||||
.thenReturn(Flux.just(mockRole()));
|
||||
var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost"));
|
||||
var authentication = authenticated(fakeUser, fakeUser.getPassword(),
|
||||
fakeUser.getAuthorities());
|
||||
|
||||
var cases = getRequestResolveCases();
|
||||
cases.forEach(requestResolveCase -> {
|
||||
var httpMethod = HttpMethod.valueOf(requestResolveCase.method);
|
||||
var request = method(httpMethod, requestResolveCase.url).build();
|
||||
var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
|
||||
StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo))
|
||||
StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo))
|
||||
.assertNext(
|
||||
visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed()))
|
||||
.verifyComplete();
|
||||
});
|
||||
|
||||
verify(roleService, times(cases.size())).listDependenciesFlux(
|
||||
Set.of("authenticated", "anonymous", "ruleReadPost"));
|
||||
verify(roleService, times(cases.size())).listDependenciesFlux(Set.of("ruleReadPost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitRulesForUserspaceScope() {
|
||||
when(roleService.listDependenciesFlux(Set.of("authenticated", "anonymous", "ruleReadPost")))
|
||||
when(roleService.listDependenciesFlux(Set.of("ruleReadPost")))
|
||||
.thenReturn(Flux.just(mockRole()));
|
||||
var fakeUser = new User("admin", "123456", createAuthorityList("ruleReadPost"));
|
||||
var authentication =
|
||||
authenticated(fakeUser, fakeUser.getPassword(), fakeUser.getAuthorities());
|
||||
var cases = List.of(
|
||||
new RequestResolveCase("/api/v1/categories", "POST", true),
|
||||
new RequestResolveCase("/api/v1/categories", "DELETE", true),
|
||||
|
@ -71,7 +76,7 @@ class DefaultRuleResolverTest {
|
|||
var httpMethod = HttpMethod.valueOf(requestResolveCase.method);
|
||||
var request = method(httpMethod, requestResolveCase.url).build();
|
||||
var requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request);
|
||||
StepVerifier.create(ruleResolver.visitRules(fakeUser, requestInfo))
|
||||
StepVerifier.create(ruleResolver.visitRules(authentication, requestInfo))
|
||||
.assertNext(
|
||||
visitor -> assertEquals(requestResolveCase.expected, visitor.isAllowed()))
|
||||
.verifyComplete();
|
||||
|
|
|
@ -21,6 +21,7 @@ ext {
|
|||
springDocOpenAPI = "2.3.0"
|
||||
lucene = "9.7.0"
|
||||
resilience4jVersion = "2.0.2"
|
||||
twoFactorAuth = "1.3"
|
||||
}
|
||||
|
||||
javaPlatform {
|
||||
|
@ -52,6 +53,7 @@ dependencies {
|
|||
api "org.thymeleaf.extras:thymeleaf-extras-springsecurity6"
|
||||
api "io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion"
|
||||
api "io.github.resilience4j:resilience4j-reactor:$resilience4jVersion"
|
||||
api "com.j256.two-factor-auth:two-factor-auth:$twoFactorAuth"
|
||||
|
||||
runtime 'org.mariadb:r2dbc-mariadb:1.1.4'
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue