【Spring Cloud】Spring Cloud Oauth2 + Gateway 微服务权限管理方案
Spring Cloud 微服务权限解决方案,通过认证服务进行统一认证,然后通过网关来统一校验认证和鉴权。通过建立用户组-用户-角色-资源之间的关系进行权限管理。
项目架构
本文采用 Eureka 作为注册中心,Spring Cloud Gateway 作为网关服务,JWT 令牌库使用 nimbus-jose-jwt
将服务分为以下几个层次:
- security-gateway:网关层,负责接收所有网络请求、转发以及权限鉴定
- security-auth:认证层,负责对登录用户进行认证
- security‐discovery:注册中心
- security-api:资源层,提供被访问的资源,用户被鉴权之后才可被访问
这样的设计使得各个服务各司其职,认证层进行认证,网关进行转发和鉴权,资源服务只专注于自己的业务逻辑,无需关心权限。也就是说安全校验逻辑只存在于认证服务和网关服务中。
权限数据库设计
在前文【Spring Security + Redis + JWT 实现动态权限管理】的基础上,构建用户组-用户-角色-资源的关系进行权限控制。与前文区别在于,最终用户拥有的权限 = 用户组对应的权限 + 用户本身对应的权限。
之所以设计用户组是为了便于管理庞大的用户数量,通过用户组可以给用户统一进行授权,同一个用户组内的用户具有公共的权限,可以减少赋权的工作量,同时允许用户拥有自己单独的权限,可以做到因人而异,更加具有适应性。
具体实现
认证服务
pom.xml 添加相关依赖,主要包括 Spring Security、Oauth2、JWT、Redis、eureka等相关依赖。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>${spring-cloud-starter-oauth2.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus-jose-jwt.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
</dependencies>
配置文件,主要配置 eureka 信息
server:
port: 9401
spring:
application:
name: security-auth
datasource:
username: root
password: "root"
url: jdbc:mysql://localhost:3306/Security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
driver-class-name: com.mysql.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8848/eureka/
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}
需要自定义 UserDetailsService
,将用户信息和权限注入进来,为后面的认证做准备(UserMapper、SysRoleMapper接口可参考前文所述)
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserMapper sysUserMapper;
@Autowired
private SysRoleMapper sysRoleMapper;
//自定义的登录逻辑
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = sysUserMapper.selectByName(username);
//根据用户名去数据库进行查询,如不存在则抛出异常
if (user == null){
throw new UsernameNotFoundException("用户不存在");
}
List<GrantedAuthority> authorities = new ArrayList<>();
// 使用用户、角色、资源、用户组建立关系,使用角色控制权限, 用户权限 = 用户个人权限+用户组权限
// 查询用户对应的权限
List<String> codeList = sysRoleMapper.selectUserRole(user.getUsername());
codeList.forEach(code ->{
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code);
authorities.add(simpleGrantedAuthority);
});
return new User(username, user.getPassword(), authorities);
}
}
配置认证服务相关配置信息,采用账号密码模式进行认证
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private UserDetailServiceImpl userDetailService;
@Resource
private JwtTokenEnhancer jwtTokenEnhancer;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Resource
private DataSource dataSource;
@Autowired
private PasswordEncoder passwordEncoder;
@Bean
public ClientDetailsService jdbcClientDetailsService() {
ClientDetailsService clientDetailsService = new JdbcClientDetailsService(dataSource);
((JdbcClientDetailsService) clientDetailsService).setPasswordEncoder(passwordEncoder);
return clientDetailsService;
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()
.passwordEncoder(passwordEncoder)
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}
// 设置客户端信息从数据库中读取
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(jdbcClientDetailsService());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
delegates.add(jwtTokenEnhancer);
delegates.add(accessTokenConverter);
enhancerChain.setTokenEnhancers(delegates); // 配置JWT的内容增强器
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailService) // 配置加载用户信息的服务
.accessTokenConverter(accessTokenConverter)
.tokenEnhancer(enhancerChain);
}
}
设置 token 的方式为 JWT,并自定义 JWT 内部的其他信息
@Configuration
public class TokenConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
jwtAccessTokenConverter.setKeyPair(keyPair());
return jwtAccessTokenConverter;
}
@Bean
public KeyPair keyPair() {
// 从classpath下的证书中获取秘钥对
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
}
JWT 增强器,自定义 JWT 内部信息
@Component
public class JwtTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
String name = oAuth2Authentication.getName();
Map<String, Object> info = new HashMap<>();
// 把用户名设置到JWT中
info.put("name", name);
((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
return oAuth2AccessToken;
}
}
暴露公钥接口以便验证签名是否合法
@RestController
public class KeyPairController {
@Autowired
private KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
配置 Spring Security 信息,开放公钥接口并且设置允许表单形式登录
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailServiceImpl userDetailService;
@Autowired
private PasswordEncoder passwordEncoder;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll()
.antMatchers("/rsa/publicKey").permitAll()
.anyRequest().authenticated().and()
.formLogin();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 自定义数据库登录逻辑
auth.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder);
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}
网关
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus-jose-jwt.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
配置文件主要配置路由转发规则以及 Oauth2 中 RSA 公钥
server:
port: 9201
spring:
application:
name: security-gateway
datasource:
username: root
password: "root"
url: jdbc:mysql://localhost:3306/Security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
driver-class-name: com.mysql.jdbc.Driver
cloud:
gateway:
routes: #配置路由路径
- id: security-api
uri: lb://security-api
predicates:
- Path=/api/**
filters:
- StripPrefix=1
- id: security-auth
uri: lb://security-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
discovery:
locator:
enabled: true #开启从注册中心动态创建路由的功能
lower-case-service-id: true #使用小写服务名,默认是大写
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: 'http://localhost:9401/rsa/publicKey' #配置RSA的公钥访问地址
redis:
database: 0
port: 6379
host: localhost
password:
secure:
ignore:
urls: #配置白名单路径
- "/actuator/**"
- "/auth/oauth/token"
eureka:
client:
service-url: # eureka的地址信息
defaultZone: http://localhost:8848/eureka/
# 将ip配置到eureka里面,不给就是host名会配置到那个里面
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
对网关服务进行安全信息配置,并使用注解开启
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
@Autowired
private AuthorizationManager authorizationManager;
@Autowired
private WhiteUrlsConfig whiteUrlsConfig;
@Autowired
private RestAccessDeniedHandler restAccessDeniedHandler;
@Autowired
private RestAuthenticationEntryPoint restAuthenticationEntryPoint;
@Autowired
private WhiteUrlsRemoveJwtFilter whiteUrlsRemoveJwtFilter;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
//自定义处理JWT请求头过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(restAuthenticationEntryPoint);
//对白名单路径,直接移除JWT请求头
http.addFilterBefore(whiteUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
http.authorizeExchange()
.pathMatchers(ArrayUtil.toArray(whiteUrlsConfig.getUrls(), String.class)).permitAll() // 白名单配置
.anyExchange().access(authorizationManager) // 鉴权管理器配置
.and().exceptionHandling()
.accessDeniedHandler(restAccessDeniedHandler) // 处理未授权异常
.authenticationEntryPoint(restAuthenticationEntryPoint) // 处理未认证异常
.and().csrf().disable();
return http.build();
}
// JWT解析器
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(CommonConstant.AUTHORITY_CLAIM_NAME);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
设置 jwtGrantedAuthoritiesConverter.setAuthorityPrefix()的内容可以视 sys_role 表 code 字段的具体情况而定,能够与Spring Security默认的角色标识是 ROLE 开头而Oauth2默认的角色标识是 SCOPE 匹配即可。
CommonConstant.AUTHORITY_CLAIM_NAME 常量的内容是"authorities"
白名单过滤器,过滤白名单内的请求,可以直接放行
@Data
@Component
@EqualsAndHashCode(callSuper = false)
@ConfigurationProperties(prefix="secure.ignore")
public class WhiteUrlsConfig {
private List<String> urls;
}
同时,白名单内的请求可以去掉 JWT 请求头,减少负载信息
@Component
public class WhiteUrlsRemoveJwtFilter implements WebFilter {
@Autowired
private WhiteUrlsConfig whiteUrlsConfig;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
// 获取当前路径
ServerHttpRequest request = exchange.getRequest();
URI uri = request.getURI();
PathMatcher pathMatcher = new AntPathMatcher();
List<String> whiteUrls = whiteUrlsConfig.getUrls();
for (String url : whiteUrls) {
// 若为白名单路径则移除JWT请求头
if (pathMatcher.match(url, uri.getPath())) {
request = exchange.getRequest().mutate().header("Authorization", "").build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}
}
return chain.filter(exchange);
}
}
实现ReactiveAuthorizationManager
鉴权决策器接口实现具体鉴权功能
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private static final Logger logger = LoggerFactory.getLogger(AuthorizationManager.class);
@Autowired
private RedisUtils redisUtils;
@Autowired
private SysRoleMapper sysRoleMapper;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
// 获取当前访问路径
URI uri = authorizationContext.getExchange().getRequest().getURI();
// 获取可访问当前路径的所有角色
Object roles = redisUtils.hashGet(RedisConstant.RESOURCE_ROLES_PATH, uri.getPath());
logger.info("current request path: {}", uri.getPath());
List<String> authorities = roles == null ?
sysRoleMapper.selectUrlRole(uri.getPath()) : Convert.toList(String.class, roles);
logger.info("current path authorities: {}", JSON.toJSON(authorities));
//认证通过且角色匹配的用户可访问当前路径
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authorities::contains)
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
同时,为了便于资源服务使用,通过实现全局过滤器将 JWT 信息解析后“换成”用户个人信息并写入回请求头。
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final static Logger logger = LoggerFactory.getLogger(AuthGlobalFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
try {
// 从token中解析用户信息并设置到Header中去
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
logger.info("AuthGlobalFilter.filter() user:{}",userStr);
ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
e.printStackTrace();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
自定义无权限访问处理器(可参考前文实现)
@Component
public class RestAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange serverWebExchange, AccessDeniedException e) {
ServerHttpResponse response = serverWebExchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String body= JSONUtil.toJsonStr(CommonResultUtil.fail(ResponseCode.NO_PERMISSION));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
return response.writeWith(Mono.just(buffer));
}
}
注册中心
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
配置文件
spring:
application:
name: security‐discovery
server:
port: 8848 #启动端口
eureka:
instance:
hostname: localhost
client:
fetch-registry: false #是否从Eureka Server获取注册信息
register-with-eureka: false #是否将自己注册到Eureka Server
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/ #服务地址
启动类
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServer {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServer.class, args);
}
}
资源服务
配置文件
server:
port: 9501
spring:
application:
name: security-api
eureka:
client:
service-url:
defaultZone: http://localhost:8848/eureka/ #注册中心地
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}:${spring.application.instance_id:${server.port}}
新建一个 controller 层测试效果
@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/get")
public String test(){
return "hello, world!";
}
@GetMapping("/get2")
public String test2(){
return "hello, cloud!";
}
@GetMapping("/other")
public String test3(){
return "hello, other!";
}
}
访问时,先使用密码模式获取 JWT 令牌,访问地址:http://localhost:9201/auth/oauth/token 即可获得返回的 token 信息
将返回的 token 放入请求头,访问有权限访问的接口即可正常访问
访问无权访问的接口,提示权限不足
更多推荐
所有评论(0)