Support TOTP two-factor authentication for backend

Signed-off-by: John Niang <johnniang@foxmail.com>
pull/4737/head
John Niang 2024-01-15 15:22:06 +08:00
parent 5fab8aca5a
commit 7946585bb5
44 changed files with 1410 additions and 539 deletions

View File

@ -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'

View File

@ -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;

View File

@ -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()

View File

@ -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)

View File

@ -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());
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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));
});
}

View File

@ -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);
});
}
}

View File

@ -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);
});
}
}
}

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}

View File

@ -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;
});
}
}

View File

@ -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));
}
}

View File

@ -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);
});
}
}
}

View File

@ -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));
});
}
}

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
});
}
}

View File

@ -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;
}
}

View File

@ -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)));
}
}

View File

@ -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);
}

View File

@ -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);
}
});
}
}
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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

View File

@ -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.
*

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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));
}
}

View File

@ -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

View File

@ -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);

View File

@ -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();
}

View File

@ -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());
}

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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'
}