Skip to content

Commit

Permalink
Support TOTP two-factor authentication for backend
Browse files Browse the repository at this point in the history
Signed-off-by: John Niang <[email protected]>
  • Loading branch information
JohnNiang committed Jan 15, 2024
1 parent 5fab8ac commit 7946585
Show file tree
Hide file tree
Showing 44 changed files with 1,410 additions and 539 deletions.
2 changes: 2 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 4 additions & 2 deletions api/src/main/java/run/halo/app/core/extension/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -72,6 +72,8 @@ public static class UserSpec {

private Boolean twoFactorAuthEnabled;

private String totpEncryptedSecret;

private Boolean disabled;

private Integer loginHistoryLimit;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
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;
Expand All @@ -42,6 +41,7 @@
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;

/**
Expand All @@ -67,7 +67,11 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http,
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);
Expand All @@ -79,12 +83,11 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http,
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@
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
Expand Down Expand Up @@ -542,10 +544,11 @@ record ChangePasswordRequest(
@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)
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

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;
Expand All @@ -16,6 +20,7 @@
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 {
Expand Down Expand Up @@ -43,15 +48,19 @@ public Mono<UserDetails> findByUsername(String username) {
.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));
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
}

}
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
}
Loading

0 comments on commit 7946585

Please sign in to comment.