示例项目地址: https://git.code.tencent.com/xinzhang0618/oa.git
参考文档:
使用JDK生成公钥: https://blog.csdn.net/mmingxiang/article/details/108611390
公钥的使用: https://blog.csdn.net/qq_15973399/article/details/106903103
实现思路
(使用oauth2+jwt+gateway+rsa)
做认证主要是登录, 访问刷新, token过期, 登出
- 登录, 前端组装oauth2的url访问gateway, 直接跳转auth服务获取token;
- 访问刷新, gateway添加后置过滤器, 当token快过期时, 重新请求auth服务获取token, 请求头添加token刷新的事件, 前端接收后刷新token
- token过期, jwt过期
- 登录, redis添加token的黑名单, 过期时间为token的过期时间, 每次访问校验token不能存在于黑名单
尴尬点:
- oauth2本用作第三方登录, 用在这里只为做一个token生成器, 若应用有多个前端(web, app等), 则更有应用场景一些; 且oauth2有诸多限制, 获取token以及刷新有固定的url, 导致appSecret暴露在url且刷新极为不便; 导致这套体系之下, token的刷新使用”重新获取”更便利些; 所以, 建议在一般的分布式认证中, 直接用jwt就够了
- 在gateway重新获取token依然不便, 不能将用户密码以及appSecret等暴露在jwt中, 因此重新获取token, 还是需要在gateway请求auth服务拿到用户以及客户端信息, 然后拼接url重新请求oauth2
- jwt是无状态的, 但是用户登陆必须要可以登出; 此处有两种方案, 一是利用短时效的token, 允许用户登出后token依然有效; 另一种就是借助redis给token加上过期的状态, 但每次访问就都要走redis校验了
- 此外oauth2的sso方案之前尝试过, 配置虽然简单, 但资源服务器会在每次请求时都访问鉴权服务器以校验token, 增加了延时, 不建议用
- 纯吐槽, oauth2的配置网上的版本颇多, 甚至不同依赖不同配置, 坑也非常非常多, 这部分我尽量写详细
配置解析
项目采用通用的结构
- auth-认证鉴权服务
- gateway-网关
- web-资源服务
认证鉴权服务
AuthServerConfig, oauth2认证服务器配置<!--oauth2-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>Greenwich.SR3</version>
</dependency>
<!--jwt-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
- @EnableResourceServer, @EnableAuthorizationServer俩注解都不能掉, 它是认证服务器的同事也是资源服务器, 这样才能保证携带token能访问auth服务(便于后续的token刷新/重新获取)
- auth服务存放.jks文件(gateway存放pubkey.txt), 至于秘钥库和公钥的生成, 以及配置, 详见文档顶部的参考文档
- 自定义客户端的获取(oauth2的客户端模式通用)—>CustomClientDetailsService, 自定义用户信息的获取(oauth2的密码模式通用)—>CustomUserDetailsService, JWT的使用—>JWTTokenEnhancer
- AuthenticationManager这个bean一定要定义, 参见下方WebSecurityConfig中定义了 ```java package top.xinzhang0618.oa.config;
import java.security.KeyPair; import java.util.ArrayList; import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.bootstrap.encrypt.KeyProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.oauth2.common.exceptions.OAuth2Exception; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; import org.springframework.security.oauth2.provider.error.DefaultWebResponseExceptionTranslator; import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator; import org.springframework.security.oauth2.provider.token.TokenEnhancer; import org.springframework.security.oauth2.provider.token.TokenEnhancerChain; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;
import javax.annotation.Resource;
/**
- oauth2认证服务器配置 *
- @author xinzhang
@date 2020/11/16 17:19 */ @Configuration @EnableAuthorizationServer @EnableResourceServer public class AuthServerConfig extends AuthorizationServerConfigurerAdapter { @Resource(name = “keyProp”) private KeyProperties keyProperties;
@Bean(“keyProp”) public KeyProperties keyProperties() {
return new KeyProperties();
}
@Autowired private AuthenticationManager authenticationManager; @Autowired private JWTTokenEnhancer jwtTokenEnhancer; @Autowired private CustomClientDetailsService clientDetailsService; @Autowired private CustomUserDetailsService userDetailsService;
// /*
// 辅助排查问题, 保留
// @return
// /
// @Bean
// public WebResponseExceptionTranslator loggingExceptionTranslator() {
// return new DefaultWebResponseExceptionTranslator() {
// @Override
// public ResponseEntity
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
KeyPair keyPair = (new KeyStoreKeyFactory(this.keyProperties.getKeyStore().getLocation(),
this.keyProperties.getKeyStore().getSecret().toCharArray())).getKeyPair(this.keyProperties.getKeyStore().getAlias(),
this.keyProperties.getKeyStore().getPassword().toCharArray());
converter.setKeyPair(keyPair);
return converter;
}
@Bean
public JwtTokenStore jwtTokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancers = new ArrayList<>(2);
enhancers.add(jwtTokenEnhancer);
enhancers.add(accessTokenConverter());
tokenEnhancerChain.setTokenEnhancers(enhancers);
endpoints.tokenStore(jwtTokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.accessTokenConverter(accessTokenConverter())
.userDetailsService(userDetailsService)
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
// .exceptionTranslator(loggingExceptionTranslator());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.tokenKeyAccess("permitAll()")
.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(clientDetailsService);
}
}
**WebSecurityConfig, web安全配置**
1. PasswordEncoder这个bean**一定要定义**
1. AuthenticationManager这个bean**一定要定义**
```java
package top.xinzhang0618.oa.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* @author xinzhang
* @date 2020/11/16 18:13
*/
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/auth/oauth/**").permitAll()
.and()
.csrf().disable();
}
}
上面俩爹已经搞定了, 剩余的配置基本就贴代码了, 比较简单, 不多说
CustomClientDetailsService, 注意客户端密码加密
package top.xinzhang0618.oa.config;
import java.util.Arrays;
import java.util.Collections;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.ClientRegistrationException;
import org.springframework.security.oauth2.provider.client.BaseClientDetails;
import org.springframework.stereotype.Service;
import top.xinzhang0618.oa.domain.base.Client;
import top.xinzhang0618.oa.service.base.ClientService;
/**
* @author xinzhang
* @date 2020/11/16 18:05
*/
@Service
public class CustomClientDetailsService implements ClientDetailsService {
@Autowired
private ClientService clientService;
@Override
public ClientDetails loadClientByClientId(String appKey) throws ClientRegistrationException {
Client client = clientService.getOne(new LambdaQueryWrapper<Client>().eq(Client::getAppKey, appKey));
BaseClientDetails clientDetails = new BaseClientDetails();
clientDetails.setClientId(appKey);
clientDetails.setClientSecret( new BCryptPasswordEncoder().encode(client.getAppSecret()));
clientDetails.setAuthorizedGrantTypes(Arrays.asList(client.getGrantType().split(",")));
clientDetails.setScope(Collections.singletonList("all"));
clientDetails.setAccessTokenValiditySeconds(client.getTokenValidityHours() * 60 * 60);
clientDetails.setRefreshTokenValiditySeconds(client.getRefreshTokenValidityHours() * 60 * 60);
return clientDetails;
}
}
客户端表结构设计
CREATE TABLE `oa_client` (
`client_id` bigint(20) unsigned NOT NULL COMMENT '客户端id',
`created_time` datetime NOT NULL COMMENT '创建时间',
`modified_time` datetime NOT NULL COMMENT '更新时间',
`tenant_id` bigint(20) unsigned NOT NULL COMMENT '租户id',
`is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否删除',
`app_key` varchar(50) NOT NULL COMMENT '应用key',
`app_secret` varchar(100) NOT NULL COMMENT '应用密码',
`grant_type` varchar(200) NOT NULL COMMENT '授权类型',
`token_validity_hours` int(11) NOT NULL COMMENT 'token有效期',
`refresh_token_validity_hours` int(11) NOT NULL COMMENT 'refreshToken有效期',
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端';
CustomUserDetailsService, 注意用户名密码加密, 以及此处权限的处理
package top.xinzhang0618.oa.config;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import top.xinzhang0618.oa.Assert;
import top.xinzhang0618.oa.domain.base.User;
import top.xinzhang0618.oa.service.base.PrivilegeService;
import top.xinzhang0618.oa.service.base.UserService;
import javax.annotation.Resource;
/**
* @author xinzhang
* @date 2020/11/16 17:53
*/
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private PrivilegeService privilegeService;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
User user = userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getUserName, userName)
.eq(User::isEnable, true));
if (Assert.isNull(user)) {
throw new UsernameNotFoundException("用户不存在");
}
// String encode = new BCryptPasswordEncoder().encode(user.getPassword());
user.setPassword( new BCryptPasswordEncoder().encode(user.getPassword()));
List<Long> privileges = privilegeService.listUserPrivileges(user.getUserId());
Set<SimpleGrantedAuthority> authorities =
privileges.stream().map(p -> new SimpleGrantedAuthority(String.valueOf(privileges))).collect(Collectors.toSet());
return new UserBO(user,authorities);
}
}
UserBO
package top.xinzhang0618.oa.config;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import top.xinzhang0618.oa.domain.base.User;
import java.util.Collection;
import java.util.Set;
/**
* @author xinzhang
* @date 2020/11/17 19:28
*/
public class UserBO implements UserDetails {
public UserBO(User user, Collection<? extends GrantedAuthority> authorities) {
this.userId = user.getUserId();
this.userName = user.getUserName();
this.password = user.getPassword();
}
private Long userId;
private String userName;
private String password;
private String tenantId;
private Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.userName;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public void setPassword(String password) {
this.password = password;
}
public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = authorities;
}
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
}
其余
AuthController
package top.xinzhang0618.oa;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.web.bind.annotation.*;
import top.xinzhang0618.oa.bo.auth.AuthInfoBO;
import top.xinzhang0618.oa.constant.AuthConstant;
import top.xinzhang0618.oa.domain.base.Client;
import top.xinzhang0618.oa.domain.base.User;
import top.xinzhang0618.oa.service.base.ClientService;
import top.xinzhang0618.oa.service.base.UserService;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
/**
* @author xinzhang
* @date 2020/11/19 14:19
*/
@RestController
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private ClientService clientService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@GetMapping("/info/{appKey}/{userId}")
public AuthInfoBO getAuthInfo(@PathVariable("appKey") String appKey, @PathVariable("userId") Long userId) {
User user = userService.getById(userId);
Client client = clientService.getOne(new LambdaQueryWrapper<Client>().eq(Client::getAppKey, appKey));
AuthInfoBO authInfoBO = new AuthInfoBO();
authInfoBO.setUserName(user.getUserName());
authInfoBO.setPassword(user.getPassword());
authInfoBO.setAppKey(client.getAppKey());
authInfoBO.setAppSecret(client.getAppSecret());
return authInfoBO;
}
@PostMapping("/exit")
public void logout(@RequestHeader("Authorization") String authorization) {
String token = authorization.replace(AuthConstant.TOKEN_PREFIX, "");
OAuth2AccessToken oAuth2AccessToken = new JwtTokenStore(accessTokenConverter).readAccessToken(token);
String key = AuthConstant.TOKEN_BLACK_LIST_PREFIX + authorization;
redisTemplate.opsForValue().set(key, LocalDateTime.now().toString());
redisTemplate.expire(key, oAuth2AccessToken.getExpiresIn(), TimeUnit.SECONDS);
}
}
.jks的配置
encrypt:
key-store:
location: classpath:config/oa.jks
secret: xxx
alias: xx
password: xxx
获取token的路径示例
注意其中客户端以及密码均采用密文
http://localhost:40002/auth/oauth/token?grant_type=password&client_id=test&client_secret=$2a$10$ATXY1ablVzcML1aNFkZrbuD3oVvddBw62JXfOSZ4zQgrJAOqOfOM2&username=test&password=$2a$10$ATXY1ablVzcML1aNFkZrbuD3oVvddBw62JXfOSZ4zQgrJAOqOfOM2
网关
<!--webflux-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!--gateway-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--oauth2-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
spring:
cloud:
gateway:
routes:
# web服务路由
- id: web-router
uri: lb://WEB
predicates:
- Path=/api/**
# auth服务路由
- id: auth-router
uri: lb://AUTH
predicates:
- Path=/auth/**
redis:
host: 39.106.55.179
port: 6379
database: 0
password: xxx
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 5000
以下的几个核心配置类相当僵硬, 稍有改动可能会导致Bean加载顺序出问题而报错, 但仍有优化空间
ResourceServerConfig, oauth2资源服务器配置
- 配置全局跨域
- gateway存放pubkey.txt(auth服务存放.jks文件), 配置token转换器方便解析token
- 注册权限验证器ReactiveAuthenticationManager和认证验证器ReactiveAuthorizationManager
```java package top.xinzhang0618.oa.config;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authentication.ReactiveAuthenticationManager; 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.oauth2.provider.token.store.JwtAccessTokenConverter; import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; import org.springframework.security.oauth2.server.resource.web.server.ServerBearerTokenAuthenticationConverter; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.web.cors.reactive.CorsUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import reactor.core.publisher.Mono; import top.xinzhang0618.oa.util.FileUtils;
/**
- @author xinzhang
@date 2020/11/17 19:43 */ @Configuration @EnableWebFluxSecurity public class ResourceServerConfig {
private static final String MAX_AGE = “18000L”;
@Autowired private AccessManager accessManager; @Autowired private RedisTemplate
redisTemplate; /**
跨域配置 */ public WebFilter corsFilter() { return (ServerWebExchange ctx, WebFilterChain chain) -> {
ServerHttpRequest request = ctx.getRequest();
if (CorsUtils.isCorsRequest(request)) {
HttpHeaders requestHeaders = request.getHeaders();
ServerHttpResponse response = ctx.getResponse();
HttpMethod requestMethod = requestHeaders.getAccessControlRequestMethod();
HttpHeaders headers = response.getHeaders();
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, requestHeaders.getOrigin());
headers.addAll(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
requestHeaders.getAccessControlRequestHeaders());
if (requestMethod != null) {
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, requestMethod.name());
}
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "*");
headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, MAX_AGE);
if (request.getMethod() == HttpMethod.OPTIONS) {
response.setStatusCode(HttpStatus.OK);
return Mono.empty();
}
}
return chain.filter(ctx);
}; }
@Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setVerifierKey(FileUtils.read(“config/pubkey.txt”)); return converter; }
@Bean SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) { //token管理器-jwt实现类 ReactiveAuthenticationManager tokenAuthenticationManager =
new JwtAuthenticationManager(new JwtTokenStore(accessTokenConverter()),redisTemplate);
//认证过滤器 AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(tokenAuthenticationManager); authenticationWebFilter.setServerAuthenticationConverter(new ServerBearerTokenAuthenticationConverter());
http.httpBasic().disable()
.csrf().disable()
.authorizeExchange()
.pathMatchers(HttpMethod.OPTIONS).permitAll()
.anyExchange().access(accessManager)
.and()
.addFilterAt(corsFilter(), SecurityWebFiltersOrder.CORS)
.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
return http.build(); } }
**AccessManager, 实现ReactiveAuthorizationManager<AuthorizationContext>接口做鉴权**<br />这里可以设定放开的静态资源路径, 对客户端做路径的权限控制, 利用正则对用户请求路径做权限控制等, <br />对于分布式后端做鉴权, 两种方案:
1. 使用restFul风格的url, 那只能使用正则表达式以实现对资源路径的校验;
1. 全部使用POST作为请求方式, 同样数据库维护url的资源权限;
不论哪种, 维护都较为繁琐, 且使用正则性能低, 目前个人没发现好的解决方案, 因此示例项目这部分没做, 仅由前端控制权限
```java
package top.xinzhang0618.oa.config;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashSet;
import java.util.Set;
/**
* @author xinzhang
* @date 2020/11/18 17:40
*/
@Component
public class AccessManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final Set<String> permitAll = new HashSet<>();
private static final AntPathMatcher antPathMatcher = new AntPathMatcher();
public AccessManager() {
permitAll.add("/");
permitAll.add("/auth/oauth/**");
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerWebExchange exchange = authorizationContext.getExchange();
String requestPath = exchange.getRequest().getURI().getPath();
if (permitAll(requestPath)) {
return Mono.just(new AuthorizationDecision(true));
}
return mono.map(auth -> new AuthorizationDecision(checkAuthorities(auth, requestPath)))
.defaultIfEmpty(new AuthorizationDecision(false));
}
/**
* 校验是否属于静态资源
*
* @param requestPath 请求路径
* @return
*/
private boolean permitAll(String requestPath) {
return permitAll.stream().anyMatch(r -> antPathMatcher.match(r, requestPath));
}
/**
* 权限校验
*
* @param auth Authentication
* @param requestPath 请求路径
* @return
*/
private boolean checkAuthorities(Authentication auth, String requestPath) {
if (auth instanceof OAuth2Authentication) {
OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) auth;
String clientId = oAuth2Authentication.getOAuth2Request().getClientId();
// 权限校验
}
return true;
}
}
JwtAuthenticationManager实现ReactiveAuthenticationManager接口做认证
认证就一个authenticate方法较为简单, 注意实现的逻辑有:
- 先过滤redis黑名单
- jwt的解析
- token的过期校验
- token鉴权 ```java package top.xinzhang0618.oa.config;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import reactor.core.publisher.Mono;
/**
- @author xinzhang
@date 2020/11/17 19:53 */ public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
private RedisTemplate
redisTemplate; private TokenStore tokenStore; public JwtAuthenticationManager(TokenStore tokenStore, RedisTemplate
redisTemplate) { this.tokenStore = tokenStore;
this.redisTemplate = redisTemplate;
}
@Override public Mono
authenticate(Authentication authentication) { return Mono.justOrEmpty(authentication)
.filter(a -> a instanceof BearerTokenAuthenticationToken)
.cast(BearerTokenAuthenticationToken.class)
.map(BearerTokenAuthenticationToken::getToken)
.flatMap((accessToken -> {
// redis黑名单
if (redisTemplate.opsForValue().get("TOKEN_BLACK_LIST:Bearer " + accessToken) != null) {
return Mono.error(new InvalidTokenException("请重新登录"));
}
OAuth2AccessToken oAuth2AccessToken;
try {
oAuth2AccessToken = this.tokenStore.readAccessToken(accessToken);
} catch (Exception e) {
return Mono.error(new InvalidTokenException("token无效, 请重新登录"));
}
if (oAuth2AccessToken == null) {
return Mono.error(new InvalidTokenException("请先登录"));
} else if (oAuth2AccessToken.isExpired()) {
return Mono.error(new InvalidTokenException("登录已过期, 请重新登录"));
}
OAuth2Authentication oAuth2Authentication = this.tokenStore.readAuthentication(accessToken);
if (oAuth2Authentication == null) {
return Mono.error(new InvalidTokenException("登录无效, 请重新登录"));
} else {
return Mono.just(oAuth2Authentication);
}
})).cast(Authentication.class);
} }
**AuthGlobalFilter实现GlobalFilter, 作为校验通过后的过滤器**
1. 注意这里有个骚操作是"往SecurityContext中塞入token, 方便restTemplate取出并携带", 是为了后续访问auth服务重新获取token所必须的, oauth2+webflux下的的SecurityContext中啥都没有
1. 判断token有效期, 只剩5分钟时, 重新获取token, 并在请求头添加token刷新的事件
1. 解析jwt内容塞入上下文
```java
package top.xinzhang0618.oa.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import top.xinzhang0618.oa.BizContext;
import top.xinzhang0618.oa.constant.GatewayConstant;
import top.xinzhang0618.oa.util.StringUtils;
import top.xinzhang0618.oa.util.TokenUtil;
import java.util.Map;
/**
* @author xinzhang
* @date 2020/11/17 19:55
*/
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Autowired
private TokenUtil tokenUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(GatewayConstant.AUTHORIZATION);
if (StringUtils.isEmpty(token)) {
return chain.filter(exchange);
}
// 往SecurityContext中塞入token, 方便restTemplate取出并携带
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(new BearerTokenAuthenticationToken(token));
OAuth2AccessToken oAuth2AccessToken = new JwtTokenStore(accessTokenConverter).readAccessToken(token.replace(
GatewayConstant.TOKEN_PREFIX, ""));
Map<String, Object> map = oAuth2AccessToken.getAdditionalInformation();
String userId = (String) map.get(GatewayConstant.USER_ID);
String appKey = (String) map.get(GatewayConstant.APP_KEY);
// token有效期只剩5分钟时刷新token
if (oAuth2AccessToken.getExpiresIn() < GatewayConstant.TOKEN_REFRESH_TIME_LIMIT) {
String newToken = tokenUtil.generateToken((String) map.get(GatewayConstant.USER_ID), (String) map.get(GatewayConstant.APP_KEY));
HttpHeaders headers = exchange.getResponse().getHeaders();
headers.add(GatewayConstant.ACCESS_TOKEN, newToken);
headers.add(GatewayConstant.EVENT, GatewayConstant.TOKEN_REFRESHED);
}
// 设置上下文
BizContext.setUserId((Long) map.get(GatewayConstant.USER_ID));
BizContext.setUserName((String) map.get(GatewayConstant.USER_NAME));
BizContext.setTenantId((Long) map.get(GatewayConstant.TENANT_ID));
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
补充:
RestTemplateConfig, 注意下拦截器, 从SecurityContext中取出token并携带
package top.xinzhang0618.oa.config.rest;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
import org.springframework.web.client.RestTemplate;
import top.xinzhang0618.oa.constant.GatewayConstant;
/**
* RestTemplate配置类
*
* @author gavin
* @date 2020-07-01
* 文档: https://docs.spring.io/spring/docs/4.3.9.RELEASE/spring-framework-reference/html/remoting.html#rest-client-access
* api: https://docs.spring.io/spring-framework/docs/4.3.9
* .RELEASE/javadoc-api/org/springframework/web/client/RestTemplate.html
*/
@Configuration
public class RestTemplateConfig {
private static final Logger LOGGER = LoggerFactory.getLogger(RestTemplateConfig.class);
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory());
restTemplate.getInterceptors().add(authorizedRequestInterceptor());
replaceJackson2FastJson(restTemplate);
return restTemplate;
}
/**
* 自定义拦截器携带authorization
*/
@Bean(name = "authorizedRequestInterceptor")
public ClientHttpRequestInterceptor authorizedRequestInterceptor() {
return (httpRequest, bytes, clientHttpRequestExecution) -> {
HttpHeaders headers = httpRequest.getHeaders();
BearerTokenAuthenticationToken authentication =
(BearerTokenAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
headers.add(GatewayConstant.AUTHORIZATION, authentication.getToken());
return clientHttpRequestExecution.execute(httpRequest, bytes);
};
}
/**
* 替换默认的jackson转换器为fastJson转换器
*/
private void replaceJackson2FastJson(RestTemplate restTemplate) {
List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters();
//原有的String是ISO-8859-1编码 替换成 UTF-8编码
converters.removeIf(c -> c instanceof StringHttpMessageConverter);
converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8));
converters.add(0, fastJsonHttpMessageConverter());
}
/**
* 配置fastJson转换器
*/
@Bean
public HttpMessageConverter fastJsonHttpMessageConverter() {
FastJsonConfig fastJsonConfig = new FastJsonConfig();
fastJsonConfig.setSerializerFeatures(SerializerFeature.WriteMapNullValue, SerializerFeature.QuoteFieldNames,
SerializerFeature.WriteNullStringAsEmpty, SerializerFeature.WriteNullListAsEmpty,
SerializerFeature.DisableCircularReferenceDetect);
FastJsonHttpMessageConverter fastJsonHttpMessageConverter = new FastJsonHttpMessageConverter();
fastJsonHttpMessageConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON_UTF8,
MediaType.TEXT_HTML));
fastJsonHttpMessageConverter.setFastJsonConfig(fastJsonConfig);
return fastJsonHttpMessageConverter;
}
/**
* 配置clientHttpRequestFactory
*/
@Bean
public HttpComponentsClientHttpRequestFactory clientHttpRequestFactory() {
try {
HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
//设置连接池
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
//最大连接数
connectionManager.setMaxTotal(20);
//同路由并发数
connectionManager.setDefaultMaxPerRoute(10);
httpClientBuilder.setConnectionManager(connectionManager);
HttpClient httpClient = httpClientBuilder.build();
// httpClient连接配置
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
httpClient);
//连接超时
requestFactory.setConnectTimeout(60 * 1000);
//数据读取超时时间
requestFactory.setReadTimeout(60 * 1000);
//连接不够用的等待时间
requestFactory.setConnectionRequestTimeout(60 * 1000);
return requestFactory;
} catch (Exception e) {
LOGGER.error(String.format("初始化clientHttpRequestFactory失败, 错误信息: %s", e));
}
return null;
}
}
网关全局异常处理
ExceptionConfig, 主要是定义ErrorWebExceptionHandler的Bean
package top.xinzhang0618.oa.config.exception;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.result.view.ViewResolver;
import java.util.Collections;
import java.util.List;
/**
* @author xinzhang
* @date 2020/11/20 16:22
*/
@Configuration
@EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class})
public class ExceptionConfig {
private final ServerProperties serverProperties;
private final ApplicationContext applicationContext;
private final ResourceProperties resourceProperties;
private final List<ViewResolver> viewResolvers;
private final ServerCodecConfigurer serverCodecConfigurer;
public ExceptionConfig(ServerProperties serverProperties,
ResourceProperties resourceProperties,
ObjectProvider<List<ViewResolver>> viewResolversProvider,
ServerCodecConfigurer serverCodecConfigurer,
ApplicationContext applicationContext) {
this.serverProperties = serverProperties;
this.applicationContext = applicationContext;
this.resourceProperties = resourceProperties;
this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
this.serverCodecConfigurer = serverCodecConfigurer;
}
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) {
JsonExceptionHandler exceptionHandler = new JsonExceptionHandler(
errorAttributes,
this.resourceProperties,
this.serverProperties.getError(),
this.applicationContext);
exceptionHandler.setViewResolvers(this.viewResolvers);
exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters());
exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders());
return exceptionHandler;
}
}
JsonExceptionHandler, 实现默认的DefaultErrorWebExceptionHandler异常处理器, 重写方法
此处的status可能有线程安全问题, 待优化, 这个status是请求返回的http状态码, 但项目中我自定义的异常码都是10000-10003这种, 在getErrorAttributes方法又覆盖了原有的返回值, 因此会导致getHttpStatus方法报错, 暂时存放一个类变量以保存原有的status码
package top.xinzhang0618.oa.config.exception;
import com.alibaba.fastjson.JSON;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.*;
import top.xinzhang0618.oa.constant.GatewayConstant;
import top.xinzhang0618.oa.rest.response.ErrorCode;
import top.xinzhang0618.oa.rest.response.RestResponse;
import java.util.Map;
/**
* 自定义异常处理器
* 参考: https://cloud.tencent.com/developer/article/1650123
* 参考: https://blog.csdn.net/github_38924695/article/details/104374037
*
* @author xinzhang
* @date 2020/11/20 16:02
*/
public class JsonExceptionHandler extends DefaultErrorWebExceptionHandler {
/**
* 原ErrorAttributes中状态码
*/
private int status;
public JsonExceptionHandler(ErrorAttributes errorAttributes, ResourceProperties resourceProperties,
ErrorProperties errorProperties, ApplicationContext applicationContext) {
super(errorAttributes, resourceProperties, errorProperties, applicationContext);
}
/**
* 获取异常属性
*/
@Override
protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
Throwable error = super.getError(request);
this.status= (int) super.getErrorAttributes(request, includeStackTrace).get(GatewayConstant.STATUS);
return JSON.parseObject(JSON.toJSONString(RestResponse.failure(ErrorCode.UNKNOWN_ERROR.getValue(),
error.getMessage())));
}
/**
* 指定响应处理方法为JSON处理的方法
*
* @param errorAttributes
*/
@Override
protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse);
}
/**
* 根据code获取对应的HttpStatus
*
* @param errorAttributes
*/
@Override
protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) {
return HttpStatus.valueOf(status);
}
}
日志处理
网关可以添加拦截器, 记录请求的参数以及返回值, 个人觉得不太好, 增加了响应时间, 日志应该在各服务业务处理的地方做记录比较合适
断路器
hystrix是一定要配置的, 详见上面配置
限流
暂不配置, 略