Refine logic of form login and logout (#2528)

#### What type of PR is this?

/kind improvement
/kind api-change
/area core
/milestone 2.0

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

Please see b092b390b7/docs/authentication/README.md

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2506

#### Special notes for your reviewer:

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

```release-note
优化系统登录和登出逻辑
```
pull/2536/head
John Niang 2022-10-11 16:04:14 +08:00 committed by GitHub
parent fb6c3755ed
commit af8860ffb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 451 additions and 73 deletions

View File

@ -0,0 +1,111 @@
# Halo 认证方式
目前 Halo 支持的认证方式有:
- 基本认证Basic Auth
- 表单登录Form Login
计划支持的认证方式有:
- [个人令牌认证Personal Access Token](https://github.com/halo-dev/halo/issues/1309)
- [OAuth2](https://oauth.net/2/)
## 基本认证
这是最简单的一种认证方式,通过简单设置 HTTP 请求头 `Authorization: Basic xxxyyyzzz==` 即可实现认证,访问 Halo API例如
```bash
╰─❯ curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users
或者
╰─❯ echo -n "admin:P@88w0rd" | base64
YWRtaW46UEA4OHcwcmQ=
╰─❯ curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users
```
## 表单认证
这是一种比较常用的认证方式,只需提供用户名和密码以及 `CSRF 令牌`(用于防止重复提交和跨站请求伪造)。
- 表单参数
| 参数名 | 类型 | 说明 |
| ---------- | ------ | ------------------------------------- |
| username | form | 用户名 |
| password | form | 密码 |
| _csrf | form | `CSRF` 令牌。由客户端随机生成。 |
| XSRF-TOKEN | cookie | 跨站请求伪造令牌,和 `_csrf` 的值一致 |
- HTTP 200 响应
仅在请求头 `Accept` 中包含 `application/json` 时发生,响应示例如下所示:
```bash
╰─❯ curl 'http://localhost:8090/login' \
-H 'Accept: application/json' \
-H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd'
```
```bash
< HTTP/1.1 200 OK
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Content-Type: application/json
< Content-Length: 161
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< Referrer-Policy: no-referrer
< Set-Cookie: SESSION=d04db9f7-d2a6-4b7c-9845-ef790eb4a980; Path=/; HttpOnly; SameSite=Lax
```
```json
{
"username": "admin",
"authorities": [
{
"authority": "ROLE_super-role"
}
],
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true
}
```
- HTTP 302 响应
仅在请求头 `Accept` 中不包含 `application/json`才会发生,响应示例如下所示:
```bash
╰─❯ curl 'http://localhost:8090/login' \
-H 'Accept: */*' \
-H 'Cookie: XSRF-TOKEN=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw '_csrf=1ff67e0c-6f2c-4cf9-afb5-81bc1015b8e5&username=admin&password=P@88w0rd'
```
```bash
< HTTP/1.1 302 Found
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Location: /console/
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Content-Type-Options: nosniff
< X-Frame-Options: DENY
< X-XSS-Protection: 1 ; mode=block
< Referrer-Policy: no-referrer
< Set-Cookie: SESSION=9ce6ad3f-7eba-4de5-abca-650b4721c7ac; Path=/; HttpOnly; SameSite=Lax
< content-length: 0
```
未来计划支持“记住我Remember Me”功能。

View File

@ -9,15 +9,14 @@ import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.factory.PasswordEncoderFactories;
@ -28,10 +27,10 @@ import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService; import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
@ -40,8 +39,7 @@ import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.properties.JwtProperties; import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.security.DefaultUserDetailService; import run.halo.app.security.DefaultUserDetailService;
import run.halo.app.security.SuperAdminInitializer; import run.halo.app.security.SuperAdminInitializer;
import run.halo.app.security.authentication.jwt.LoginAuthenticationFilter; import run.halo.app.security.authentication.SecurityConfigurer;
import run.halo.app.security.authentication.jwt.LoginAuthenticationManager;
import run.halo.app.security.authorization.RequestInfoAuthorizationManager; import run.halo.app.security.authorization.RequestInfoAuthorizationManager;
/** /**
@ -62,11 +60,11 @@ public class WebServerSecurityConfig {
@Bean @Bean
@Order(Ordered.HIGHEST_PRECEDENCE) @Order(Ordered.HIGHEST_PRECEDENCE)
SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http,
ServerCodecConfigurer codec, RoleService roleService,
ServerResponse.Context context, ObjectProvider<SecurityConfigurer> securityConfigurers) {
UserService userService,
RoleService roleService) { http.csrf().csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
http.csrf().disable() .and()
.cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource())) .cors(corsSpec -> corsSpec.configurationSource(apiCorsConfigurationSource()))
.securityMatcher(pathMatchers("/api/**", "/apis/**", "/login", "/logout")) .securityMatcher(pathMatchers("/api/**", "/apis/**", "/login", "/logout"))
.authorizeExchange(exchanges -> .authorizeExchange(exchanges ->
@ -76,21 +74,13 @@ public class WebServerSecurityConfig {
anonymousSpec.principal(AnonymousUserConst.PRINCIPAL); anonymousSpec.principal(AnonymousUserConst.PRINCIPAL);
}) })
.httpBasic(withDefaults()) .httpBasic(withDefaults())
.formLogin(withDefaults())
.logout(withDefaults())
// for reuse the JWT authentication // for reuse the JWT authentication
.oauth2ResourceServer().jwt(); .oauth2ResourceServer().jwt();
var loginManager = new LoginAuthenticationManager( // Integrate with other configurers separately
userDetailsService(userService, roleService), securityConfigurers.orderedStream()
passwordEncoder()); .forEach(securityConfigurer -> securityConfigurer.configure(http));
var loginFilter = new LoginAuthenticationFilter(loginManager,
codec,
jwtEncoder(),
jwtProp,
context);
http.addFilterAt(loginFilter, SecurityWebFiltersOrder.FORM_LOGIN);
return http.build(); return http.build();
} }
@ -105,7 +95,9 @@ public class WebServerSecurityConfig {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration); source.registerCorsConfiguration("/api/**", configuration);
source.registerCorsConfiguration("/apis/**", configuration); source.registerCorsConfiguration("/apis/**", configuration);
// TODO Remove both login and logout path until we provide the console proxy.
source.registerCorsConfiguration("/login", configuration); source.registerCorsConfiguration("/login", configuration);
source.registerCorsConfiguration("/logout", configuration);
return source; return source;
} }

View File

@ -1,5 +1,9 @@
package run.halo.app.security; package run.halo.app.security;
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
@ -11,6 +15,7 @@ import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService; import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupKind; import run.halo.app.extension.GroupKind;
import run.halo.app.extension.exception.ExtensionNotFoundException;
public class DefaultUserDetailService public class DefaultUserDetailService
implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService { implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
@ -32,10 +37,9 @@ public class DefaultUserDetailService
@Override @Override
public Mono<UserDetails> findByUsername(String username) { public Mono<UserDetails> findByUsername(String username) {
return userService.getUser(username).flatMap(user -> { return userService.getUser(username)
final var userGvk = .flatMap(user -> {
new run.halo.app.core.extension.User().groupVersionKind(); var subject = new Subject(KIND, username, GROUP);
var subject = new Subject(userGvk.kind(), username, userGvk.group());
return roleService.listRoleRefs(subject) return roleService.listRoleRefs(subject)
.filter(this::isRoleRef) .filter(this::isRoleRef)
.map(RoleRef::getName) .map(RoleRef::getName)
@ -45,7 +49,9 @@ public class DefaultUserDetailService
.password(user.getSpec().getPassword()) .password(user.getSpec().getPassword())
.roles(roleNames.toArray(new String[0])) .roles(roleNames.toArray(new String[0]))
.build()); .build());
}); })
.onErrorMap(ExtensionNotFoundException.class,
e -> new BadCredentialsException("Invalid Credentials"));
} }
private boolean isRoleRef(RoleRef roleRef) { private boolean isRoleRef(RoleRef roleRef) {

View File

@ -0,0 +1,9 @@
package run.halo.app.security.authentication;
import org.springframework.security.config.web.server.ServerHttpSecurity;
public interface SecurityConfigurer {
void configure(ServerHttpSecurity http);
}

View File

@ -0,0 +1,16 @@
package run.halo.app.security.authentication;
import java.util.Set;
import org.springframework.http.MediaType;
import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
public enum WebExchangeMatchers {
;
public static ServerWebExchangeMatcher ignoringMediaTypeAll(MediaType... matchingMediaTypes) {
var matcher = new MediaTypeServerWebExchangeMatcher(matchingMediaTypes);
matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
return matcher;
}
}

View File

@ -0,0 +1,103 @@
package run.halo.app.security.authentication.formlogin;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import java.util.Map;
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.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.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
public class FormLoginConfigurer implements SecurityConfigurer {
private final ServerResponse.Context context;
public FormLoginConfigurer(ServerResponse.Context context) {
this.context = context;
}
@Override
public void configure(ServerHttpSecurity http) {
http.formLogin()
.authenticationSuccessHandler(new FormLoginSuccessHandler(context))
.authenticationFailureHandler(new FormLoginFailureHandler(context))
;
}
public static class FormLoginSuccessHandler implements ServerAuthenticationSuccessHandler {
private final ServerResponse.Context context;
private final ServerAuthenticationSuccessHandler defaultHandler =
new RedirectServerAuthenticationSuccessHandler("/console/");
public FormLoginSuccessHandler(ServerResponse.Context context) {
this.context = context;
}
@Override
public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange,
Authentication authentication) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON)
.matches(webFilterExchange.getExchange())
.flatMap(matchResult -> {
if (matchResult.isMatch()) {
var principal = authentication.getPrincipal();
if (principal instanceof CredentialsContainer credentialsContainer) {
credentialsContainer.eraseCredentials();
}
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(principal)
.flatMap(serverResponse ->
serverResponse.writeTo(webFilterExchange.getExchange(), context));
}
return defaultHandler.onAuthenticationSuccess(webFilterExchange,
authentication);
});
}
}
public static class FormLoginFailureHandler implements ServerAuthenticationFailureHandler {
private final ServerAuthenticationFailureHandler defaultHandler =
new RedirectServerAuthenticationFailureHandler("/console?error#/login");
private final ServerResponse.Context context;
public FormLoginFailureHandler(ServerResponse.Context context) {
this.context = context;
}
@Override
public Mono<Void> onAuthenticationFailure(WebFilterExchange webFilterExchange,
AuthenticationException exception) {
return ignoringMediaTypeAll(MediaType.APPLICATION_JSON).matches(
webFilterExchange.getExchange())
.flatMap(matchResult -> {
if (matchResult.isMatch()) {
return ServerResponse.status(HttpStatus.UNAUTHORIZED)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"error", exception.getLocalizedMessage()
))
.flatMap(serverResponse -> serverResponse.writeTo(
webFilterExchange.getExchange(), context));
}
return defaultHandler.onAuthenticationFailure(webFilterExchange, exception);
});
}
}
}

View File

@ -0,0 +1,69 @@
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.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerResponse;
import run.halo.app.infra.properties.JwtProperties;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
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,35 +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.CodecConfigurer;
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;
public class LoginAuthenticationFilter extends AuthenticationWebFilter {
public LoginAuthenticationFilter(LoginAuthenticationManager authenticationManager,
CodecConfigurer codec,
JwtEncoder jwtEncoder,
JwtProperties jwtProp,
ServerResponse.Context responseContext) {
super(authenticationManager);
var loginMatcher = new AndServerWebExchangeMatcher(
pathMatchers(HttpMethod.POST, "/api/auth/token"),
new MediaTypeServerWebExchangeMatcher(MediaType.APPLICATION_JSON)
);
setRequiresAuthenticationMatcher(loginMatcher);
setServerAuthenticationConverter(new LoginAuthenticationConverter(codec.getReaders()));
setAuthenticationSuccessHandler(
new LoginAuthenticationSuccessHandler(jwtEncoder, jwtProp, responseContext));
setAuthenticationFailureHandler(new LoginAuthenticationFailureHandler(responseContext));
}
}

View File

@ -0,0 +1,49 @@
package run.halo.app.security.authentication.logout;
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.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.security.authentication.SecurityConfigurer;
@Component
public class LogoutConfigurer implements SecurityConfigurer {
@Override
public void configure(ServerHttpSecurity http) {
http.logout()
.logoutSuccessHandler(new LogoutSuccessHandler());
}
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

@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@ -59,6 +60,8 @@ class ExtensionConfigurationTest {
// register scheme // register scheme
schemeManager.register(FakeExtension.class); schemeManager.register(FakeExtension.class);
webClient = webClient.mutateWith(csrf());
} }
@AfterEach @AfterEach

View File

@ -7,6 +7,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -69,6 +70,7 @@ class UserEndpointTest {
var role = new Role(); var role = new Role();
role.setRules(List.of(rule)); role.setRules(List.of(rule));
when(roleService.getMonoRole("authenticated")).thenReturn(Mono.just(role)); when(roleService.getMonoRole("authenticated")).thenReturn(Mono.just(role));
webClient = webClient.mutateWith(csrf());
} }
@Nested @Nested

View File

@ -14,6 +14,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
@ -26,6 +27,7 @@ import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService; import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DefaultUserDetailServiceTest { class DefaultUserDetailServiceTest {
@ -161,13 +163,14 @@ class DefaultUserDetailServiceTest {
@Test @Test
void shouldNotFindUserDetailsByNonExistingUsername() { void shouldNotFindUserDetailsByNonExistingUsername() {
when(userService.getUser("non-existing-user")).thenReturn(Mono.empty()); when(userService.getUser("non-existing-user")).thenReturn(
Mono.error(() -> new ExtensionNotFoundException("The user was not found")));
var userDetailsMono = userDetailService.findByUsername("non-existing-user"); var userDetailsMono = userDetailService.findByUsername("non-existing-user");
StepVerifier.create(userDetailsMono) StepVerifier.create(userDetailsMono)
.expectSubscription() .expectError(AuthenticationException.class)
.verifyComplete(); .verify();
} }
UserDetails createFakeUserDetails() { UserDetails createFakeUserDetails() {

View File

@ -0,0 +1,38 @@
package run.halo.app.security.authentication;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.http.MediaType.ALL;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.TEXT_HTML;
import static run.halo.app.security.authentication.WebExchangeMatchers.ignoringMediaTypeAll;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.test.StepVerifier;
class WebExchangeMatchersTest {
@Test
void shouldNotMatchMediaTypeAll() {
assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON, ALL), true);
assertion(Set.of(APPLICATION_JSON), Set.of(ALL), false);
assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON), true);
assertion(Set.of(APPLICATION_JSON), Set.of(APPLICATION_JSON, TEXT_HTML), true);
}
void assertion(Set<MediaType> matchingMediaTypes,
Set<MediaType> acceptMediaTypes,
boolean expectMatch) {
var matcher = ignoringMediaTypeAll(matchingMediaTypes.toArray(new MediaType[0]));
MockServerHttpRequest request = MockServerHttpRequest.get("/fake")
.accept(acceptMediaTypes.toArray(new MediaType[0]))
.build();
var webExchange = MockServerWebExchange.from(request);
StepVerifier.create(matcher.matches(webExchange))
.consumeNextWith(matchResult -> assertEquals(expectMatch, matchResult.isMatch()))
.verifyComplete();
}
}

View File

@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -41,6 +42,7 @@ class JwtAuthenticationTest {
void setUp() { void setUp() {
lenient().when(roleService.getMonoRole(eq(AnonymousUserConst.Role))) lenient().when(roleService.getMonoRole(eq(AnonymousUserConst.Role)))
.thenReturn(Mono.empty()); .thenReturn(Mono.empty());
webClient = webClient.mutateWith(csrf());
} }
@Test @Test

View File

@ -4,6 +4,7 @@ import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import com.nimbusds.jwt.JWTClaimNames; import com.nimbusds.jwt.JWTClaimNames;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -41,6 +42,8 @@ class LoginTest {
.roles("USER") .roles("USER")
.build() .build()
)); ));
webClient = webClient.mutateWith(csrf());
} }
@Test @Test

View File

@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.GET;
import static org.springframework.web.reactive.function.server.RequestPredicates.PUT; import static org.springframework.web.reactive.function.server.RequestPredicates.PUT;
import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
@ -12,6 +13,7 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
@ -53,6 +55,11 @@ class AuthorizationTest {
@MockBean @MockBean
RoleService roleService; RoleService roleService;
@BeforeEach
void setUp() {
webClient = webClient.mutateWith(csrf());
}
@Test @Test
void accessProtectedApiWithoutSufficientRole() { void accessProtectedApiWithoutSufficientRole() {
when(userDetailsService.findByUsername(eq("user"))).thenReturn( when(userDetailsService.findByUsername(eq("user"))).thenReturn(