Refactor PAT authentication by making it standalone (#6878)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.20.x

#### What this PR does / why we need it:

This PR makes PAT configuration standalone and removes unused configuration related with `JWT`.

After this, we can define additional authentications in plugins with correct configuration order.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/6885/head
John Niang 2024-10-16 18:07:27 +08:00 committed by GitHub
parent db4e68b732
commit 514a05552f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 91 additions and 284 deletions

View File

@ -1,6 +1,5 @@
package run.halo.app.infra.config;
import static org.springframework.security.web.server.authentication.ServerWebExchangeDelegatingReactiveAuthenticationManagerResolver.builder;
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
import java.util.concurrent.ConcurrentHashMap;
@ -33,8 +32,6 @@ import run.halo.app.security.HaloServerRequestCache;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.impl.RsaKeyService;
import run.halo.app.security.authentication.pat.PatAuthenticationManager;
import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher;
import run.halo.app.security.authorization.AuthorityUtils;
import run.halo.app.security.session.InMemoryReactiveIndexedSessionRepository;
import run.halo.app.security.session.ReactiveIndexedSessionRepository;
@ -86,15 +83,6 @@ public class WebServerSecurityConfig {
basic.disable();
}
})
.oauth2ResourceServer(oauth2 -> {
var authManagerResolver = builder().add(
new PatServerWebExchangeMatcher(),
new PatAuthenticationManager(client, cryptoService)
)
// TODO Add other authentication mangers here. e.g.: JwtAuthenticationManager.
.build();
oauth2.authenticationManagerResolver(authManagerResolver);
})
.headers(headerSpec -> headerSpec
.frameOptions(frameSpec -> {
var frameOptions = haloProperties.getSecurity().getFrameOptions();

View File

@ -11,6 +11,7 @@ import static org.springframework.security.config.web.server.SecurityWebFiltersO
import lombok.Setter;
import org.pf4j.ExtensionPoint;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.WebFilterChainProxy;
import org.springframework.stereotype.Component;
@ -22,6 +23,8 @@ import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
// Specific an order here to control the order or security configurer initialization
@Order(-100)
public class SecurityWebFiltersConfigurer implements SecurityConfigurer {
private final ExtensionGetter extensionGetter;

View File

@ -1,70 +0,0 @@
package run.halo.app.security.authentication.jwt;
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.security.authentication.SecurityConfigurer;
/**
* TODO: Use It after 2.0.0.
*/
public class JwtAuthenticationConfigurer implements SecurityConfigurer {
private final ReactiveUserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final ServerCodecConfigurer codec;
private final JwtEncoder jwtEncoder;
private final ServerResponse.Context context;
private final JwtProperties jwtProp;
public JwtAuthenticationConfigurer(ReactiveUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder,
ServerCodecConfigurer codec,
JwtEncoder jwtEncoder,
ServerResponse.Context context,
JwtProperties jwtProp) {
this.userDetailsService = userDetailsService;
this.passwordEncoder = passwordEncoder;
this.codec = codec;
this.jwtEncoder = jwtEncoder;
this.context = context;
this.jwtProp = jwtProp;
}
@Override
public void configure(ServerHttpSecurity http) {
var loginManager = new LoginAuthenticationManager(userDetailsService, passwordEncoder);
var filter = new AuthenticationWebFilter(loginManager);
var loginMatcher = new AndServerWebExchangeMatcher(
pathMatchers(HttpMethod.POST, "/api/auth/token"),
new MediaTypeServerWebExchangeMatcher(MediaType.APPLICATION_JSON)
);
filter.setRequiresAuthenticationMatcher(loginMatcher);
filter.setServerAuthenticationConverter(
new LoginAuthenticationConverter(codec.getReaders()));
filter.setAuthenticationSuccessHandler(
new LoginAuthenticationSuccessHandler(jwtEncoder, jwtProp, context));
filter.setAuthenticationFailureHandler(new LoginAuthenticationFailureHandler(context));
http.addFilterAt(filter, SecurityWebFiltersOrder.FORM_LOGIN);
}
}

View File

@ -1,37 +0,0 @@
package run.halo.app.security.authentication.jwt;
import static org.springframework.security.authentication.UsernamePasswordAuthenticationToken.unauthenticated;
import java.util.List;
import lombok.Data;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public class LoginAuthenticationConverter implements ServerAuthenticationConverter {
private final List<HttpMessageReader<?>> reader;
public LoginAuthenticationConverter(List<HttpMessageReader<?>> reader) {
this.reader = reader;
}
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return ServerRequest.create(exchange, this.reader)
.bodyToMono(UsernamePasswordRequest.class)
.map(request -> unauthenticated(request.getUsername(), request.getPassword()));
}
@Data
public static class UsernamePasswordRequest {
private String username;
private String password;
}
}

View File

@ -1,31 +0,0 @@
package run.halo.app.security.authentication.jwt;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
public class LoginAuthenticationFailureHandler implements ServerAuthenticationFailureHandler {
private final ServerResponse.Context context;
public LoginAuthenticationFailureHandler(ServerResponse.Context context) {
this.context = context;
}
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
AuthenticationException exception) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(
Map.of("error", exception.getLocalizedMessage())
)
.flatMap(serverResponse ->
serverResponse.writeTo(webFilterExchange.getExchange(), context));
}
}

View File

@ -1,19 +0,0 @@
package run.halo.app.security.authentication.jwt;
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
public final class LoginAuthenticationManager
extends UserDetailsRepositoryReactiveAuthenticationManager {
public LoginAuthenticationManager(ReactiveUserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
super(userDetailsService);
super.setPasswordEncoder(passwordEncoder);
if (userDetailsService instanceof ReactiveUserDetailsPasswordService passwordService) {
super.setUserDetailsPasswordService(passwordService);
}
}
}

View File

@ -1,62 +0,0 @@
package run.halo.app.security.authentication.jwt;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.web.server.WebFilterExchange;
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.infra.properties.JwtProperties;
public class LoginAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
private final JwtEncoder jwtEncoder;
private final JwtProperties jwtProp;
private final ServerResponse.Context context;
public LoginAuthenticationSuccessHandler(JwtEncoder jwtEncoder, JwtProperties jwtProp,
ServerResponse.Context context) {
this.jwtEncoder = jwtEncoder;
this.jwtProp = jwtProp;
this.context = context;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
var issuedAt = Instant.now();
// TODO Make the expiresAt configurable
var expiresAt = issuedAt.plus(24, ChronoUnit.HOURS);
var headers = JwsHeader.with(jwtProp.getJwsAlgorithm()).build();
var claims = JwtClaimsSet.builder()
.issuer("Halo Owner")
.issuedAt(issuedAt)
.expiresAt(expiresAt)
// the principal is the username
.subject(authentication.getName())
.claim("scope", authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()))
.build();
var jwt = jwtEncoder.encode(JwtEncoderParameters.from(headers, claims));
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("token", jwt.getTokenValue()))
.flatMap(serverResponse -> serverResponse.writeTo(webFilterExchange.getExchange(),
this.context));
}
}

View File

@ -0,0 +1,29 @@
package run.halo.app.security.authentication.pat;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* PAT authentication converter.
*
* @author johnniang
* @since 2.20.4
*/
class PatAuthenticationConverter extends ServerBearerTokenAuthenticationConverter {
public static final String PAT_TOKEN_PREFIX = "pat_";
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return super.convert(exchange)
.cast(BearerTokenAuthenticationToken.class)
.map(BearerTokenAuthenticationToken::getToken)
.filter(token -> StringUtils.startsWith(token, PAT_TOKEN_PREFIX))
.map(token -> StringUtils.removeStart(token, PAT_TOKEN_PREFIX))
.map(BearerTokenAuthenticationToken::new);
}
}

View File

@ -1,8 +1,6 @@
package run.halo.app.security.authentication.pat;
import static org.apache.commons.lang3.StringUtils.removeStart;
import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSource;
import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX;
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;
@ -18,7 +16,6 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import reactor.core.publisher.Flux;
@ -30,14 +27,14 @@ import run.halo.app.security.PersonalAccessToken;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authorization.AuthorityUtils;
public class PatAuthenticationManager implements ReactiveAuthenticationManager {
class PatAuthenticationManager implements ReactiveAuthenticationManager {
/**
* Minimal duration gap of personal access token update.
*/
private static final Duration MIN_UPDATE_GAP = Duration.ofMinutes(1);
private final ReactiveAuthenticationManager delegate;
private final JwtReactiveAuthenticationManager delegate;
private final ReactiveExtensionClient client;
@ -52,33 +49,28 @@ public class PatAuthenticationManager implements ReactiveAuthenticationManager {
this.clock = Clock.systemDefaultZone();
}
private ReactiveAuthenticationManager getDelegate() {
private JwtReactiveAuthenticationManager getDelegate() {
var jwtDecoder = withJwkSource(signedJWT -> Flux.just(cryptoService.getJwk()))
.build();
return new JwtReactiveAuthenticationManager(jwtDecoder);
}
public void setClock(Clock clock) {
/**
* Set new clock. Only for testing.
*
* @param clock new clock
*/
void setClock(Clock clock) {
this.clock = clock;
}
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
return Mono.just(authentication)
.map(this::clearPrefix)
.flatMap(delegate::authenticate)
return delegate.authenticate(authentication)
.cast(JwtAuthenticationToken.class)
.flatMap(this::checkAndRebuild);
}
private Authentication clearPrefix(Authentication authentication) {
if (authentication instanceof BearerTokenAuthenticationToken bearerToken) {
var newToken = removeStart(bearerToken.getToken(), PAT_TOKEN_PREFIX);
return new BearerTokenAuthenticationToken(newToken);
}
return authentication;
}
private Mono<JwtAuthenticationToken> checkAndRebuild(JwtAuthenticationToken jat) {
var jwt = jat.getToken();
var patName = jwt.getClaimAsString("pat_name");

View File

@ -15,7 +15,7 @@ import run.halo.app.extension.GroupVersion;
import run.halo.app.security.PersonalAccessToken;
@Component
public class PatEndpoint implements CustomEndpoint {
class PatEndpoint implements CustomEndpoint {
private final UserScopedPatHandler patHandler;

View File

@ -0,0 +1,45 @@
package run.halo.app.security.authentication.pat;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint;
import org.springframework.security.web.server.authentication.AuthenticationWebFilter;
import org.springframework.security.web.server.authentication.ServerAuthenticationEntryPointFailureHandler;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.SecurityConfigurer;
/**
* PAT security configurer.
*
* @author johnniang
* @since 2.20.4
*/
@Component
// Specific an order here to control the order or security configurer initialization
@Order(0)
class PatSecurityConfigurer implements SecurityConfigurer {
private final ReactiveExtensionClient client;
private final CryptoService cryptoService;
public PatSecurityConfigurer(ReactiveExtensionClient client, CryptoService cryptoService) {
this.client = client;
this.cryptoService = cryptoService;
}
@Override
public void configure(ServerHttpSecurity http) {
var filter =
new AuthenticationWebFilter(new PatAuthenticationManager(client, cryptoService));
filter.setServerAuthenticationConverter(new PatAuthenticationConverter());
var entryPoint = new BearerTokenServerAuthenticationEntryPoint();
var failureHandler = new ServerAuthenticationEntryPointFailureHandler(entryPoint);
filter.setAuthenticationFailureHandler(failureHandler);
http.addFilterAt(filter, SecurityWebFiltersOrder.AUTHENTICATION);
}
}

View File

@ -1,30 +0,0 @@
package run.halo.app.security.authentication.pat;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter;
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
public class PatServerWebExchangeMatcher implements ServerWebExchangeMatcher {
public static final String PAT_TOKEN_PREFIX = "pat_";
private final ServerAuthenticationConverter authConverter =
new ServerBearerTokenAuthenticationConverter();
@Override
public Mono<MatchResult> matches(ServerWebExchange exchange) {
return authConverter.convert(exchange)
.filter(a -> a instanceof BearerTokenAuthenticationToken)
.cast(BearerTokenAuthenticationToken.class)
.map(BearerTokenAuthenticationToken::getToken)
.filter(tokenString -> StringUtils.startsWith(tokenString, PAT_TOKEN_PREFIX))
.flatMap(t -> MatchResult.match())
.onErrorResume(AuthenticationException.class, t -> MatchResult.notMatch())
.switchIfEmpty(Mono.defer(MatchResult::notMatch));
}
}

View File

@ -1,7 +1,7 @@
package run.halo.app.security.authentication.pat.impl;
package run.halo.app.security.authentication.pat;
import static run.halo.app.extension.Comparators.compareCreationTimestamp;
import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX;
import static run.halo.app.security.authentication.pat.PatAuthenticationConverter.PAT_TOKEN_PREFIX;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
@ -41,11 +41,10 @@ import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.NotFoundException;
import run.halo.app.security.PersonalAccessToken;
import run.halo.app.security.authentication.CryptoService;
import run.halo.app.security.authentication.pat.UserScopedPatHandler;
import run.halo.app.security.authorization.AuthorityUtils;
@Service
public class UserScopedPatHandlerImpl implements UserScopedPatHandler {
class UserScopedPatHandlerImpl implements UserScopedPatHandler {
private static final String ACCESS_TOKEN_ANNO_NAME = "security.halo.run/access-token";