mirror of https://github.com/halo-dev/halo
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
parent
fb6c3755ed
commit
af8860ffb6
|
@ -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)”功能。
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in New Issue