Skip to content

Commit

Permalink
Support multi-factor authentication
Browse files Browse the repository at this point in the history
Signed-off-by: John Niang <[email protected]>
  • Loading branch information
JohnNiang committed Jan 14, 2024
1 parent 6830660 commit 38c3b1d
Show file tree
Hide file tree
Showing 63 changed files with 4,101 additions and 1,678 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 Down Expand Up @@ -79,12 +78,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

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,61 @@
package run.halo.app.security;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler;
import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.mfa.MfaAuthentication;
import run.halo.app.security.authentication.mfa.MfaResponseHandler;

@Component
public class ExceptionSecurityConfigurer implements SecurityConfigurer {

private final MfaResponseHandler mfaResponseHandler;

public ExceptionSecurityConfigurer(MfaResponseHandler mfaResponseHandler) {
this.mfaResponseHandler = mfaResponseHandler;
}

@Override
public void configure(ServerHttpSecurity http) {
http.exceptionHandling(exception -> {
var mfaAccessDeniedHandler = new MfaAccessDeniedHandler();
var mfaEntry =
new DelegateEntry(mfaAccessDeniedHandler.getMatcher(), mfaAccessDeniedHandler);
var accessDeniedHandler =
new ServerWebExchangeDelegatingServerAccessDeniedHandler(mfaEntry);
accessDeniedHandler.setDefaultAccessDeniedHandler(
new BearerTokenServerAccessDeniedHandler());
exception.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler);
});
}

private class MfaAccessDeniedHandler implements ServerAccessDeniedHandler {

private final ServerWebExchangeMatcher matcher;

private MfaAccessDeniedHandler() {
matcher = exchange -> exchange.getPrincipal()
.filter(MfaAuthentication.class::isInstance)
.flatMap(a -> ServerWebExchangeMatcher.MatchResult.match())
.switchIfEmpty(Mono.defer(ServerWebExchangeMatcher.MatchResult::notMatch));
}

@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
return mfaResponseHandler.handle(exchange);
}

public ServerWebExchangeMatcher getMatcher() {
return matcher;
}
}
}
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);
});
}
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
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;
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.
*
* @author johnniang
*/
public class JwtScopesAndRolesGrantedAuthoritiesConverter
implements Converter<Jwt, Flux<GrantedAuthority>> {
implements Converter<Jwt, Flux<GrantedAuthority>> {

private final Converter<Jwt, Collection<GrantedAuthority>> delegate;

Expand All @@ -28,17 +30,23 @@ public 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);
}

Expand Down
Loading

0 comments on commit 38c3b1d

Please sign in to comment.