之所以想写这一系列,是因为之前工作过程中使用Spring Security OAuth2搭建了网关和授权服务器,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0。无论是Spring Security的风格和以及OAuth2都做了较大改动,里面甚至将授权服务器模块都移除了,导致在配置同样功能时,花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,本系列OAuth2的代码采用Spring Security6.3.0框架,所有代码都在oauth2-study项目上:https://github.com/forever1986/oauth2-study.git
目录
- 1 整体架构
- 2 前提条件准备
- 3 授权服务器
- 4 网关
- 4.1 初始化项目
- 4.2 配置nacos自动加载
- 4.3 配置redis
- 4.4 配置gateway以及资源服务器
- 5 测试服务
- 6 测试结果
前面我们通过二十几章的内容,将新版本Spring Security 6实现OAuth2的大部分内容都讲了一遍。相信没有讲到的内容,你自己现在也能通过看源代码上手了。这一章作为最后,将以一个真实生成案例,做一个授权服务器+网关的组合,作为内部微服务访问鉴权。(由于gateway默认采用的是netty作为服务器,并且支持Webflux响应式编程,因此本章也是基于上2章学习的响应式编程,搭建gateway)
1 整体架构
在微服务架构中,随着微服务越来越多,一般会采用一个网关作为统一管理,但是网关也会涉及鉴权问题,而OAuth2经常为微服务架构提供统一的认证和授权服务。这里的鉴权需要说明一下,一种是访问权限,一种是详细的业务权限,假如一个前端服务需要访问订单服务,那么订单服务就是访问权限,能访问多少订单数据则属于业务权限。而OAuth2只做访问权限管理,也就是前端服务能访问哪些后端服务,至于详细业务权限还是需要每个后端服务自己实现。因此网关结合OAuth2其实是一个url访问权限管理,底层是网关作为一个资源服务器,验证access_token以及其scope去判断url。
下面我们就通过授权服务器+网关的组合,展现真实环境下搭建一个以OAuth2为基础的网关权限认证。
1)前端应用通过登录并获得应用授权
2)授权服务器返回token
3)前端应用携带token去访问API网关
4)API网关通过验证token有效性以及鉴权情况
5)验证通过后,则转发请求
2 前提条件准备
1)mysql数据库,建立一个oauth_study数据库,里面建立4张表,分别是t_user(用户信息),oauth2_registered_client(客户端信息)、oauth2_authorization_consent(授权信息)和oauth2_authorization(授权确认信息)。SQL语句参考lesson20的create.sql附件。注意:由于数据库和表都是沿用之前项目的,如果之前项目已经新建过表,则无需在新建,只需要执行insert.sql
-- 插入一个用户lesson20
INSERT INTO oauth_study.t_user (username, password, email, phone) VALUES('lesson20', '{noop}1234', 'lesson20@demo.com', '13788888888');
-- 插入一个客户端lesson20-client,其使用client_credentials客户端模式,并且scopes包括一个api-a的值(角色)
INSERT INTO oauth_study.oauth2_registered_client (id, client_id, client_id_issued_at, client_secret, client_secret_expires_at, client_name, client_authentication_methods, authorization_grant_types, redirect_uris, post_logout_redirect_uris, scopes, client_settings, token_settings) VALUES('7f5b4fc6-9b32-42e0-89a3-012ad594a6e8', 'lesson20-client', '2025-02-11 16:04:01', '{bcrypt}$2a$10$ytMBTJDyysYDsI.aqSJche1/1de00KdPw4IeHZAMCT5T8SdxfciTq', NULL, 'lesson20-client', 'client_secret_basic', 'refresh_token,client_credentials', 'http://localhost:8080/login/oauth2/code/lesson20-client', 'http://localhost:8080/', 'api-a,openid,profile', '{"@class":"java.util.Collections$UnmodifiableMap","settings.client.require-proof-key":false,"settings.client.require-authorization-consent":true}', '{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.x509-certificate-bound-access-tokens":false,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",300.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}');
2)redis缓存数据库,用于存储登录的token以及权限数据
3)nacos注册和配置中心,用于微服务注册以及gateway网关的配置
3 授权服务器
1)授权服务器使用mysql存储用户信息以及客户端信息
2)授权服务器使用前后端分离的方式(自定义登录接口,屏蔽登录界面)
3)登录使用jwt生成token(非对称加密),屏蔽原先session
4)登录的token使用redis存储
注意:由于其功能与lesson06子模块一样,因此我们直接使用lesson06子模块,需要了解详情,请参考《系列之六 - 授权服务器–自定义授权页面》
4 网关
1)通过读取naocs实现路由配置,实现路由动态加载
2)通过读取本地的公钥,进行token验证
3)通过读取redis存储的URL与授权的scope(角色)的关系,实现鉴权判断
代码参考lesson20子模块下面的gateway子模块
4.1 初始化项目
1)在lesson20子模块下面新建gateway子模块,其pom引入
<dependencies><!-- 注意:引入spring-cloud-starter-gateway之后,并不需要在引入spring-boot-starter-web --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-gateway</artifactId></dependency><!-- 引入spring-cloud-loadbalancer --><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency><!--Spring Cloud2020之后移除了bootstrap加载,需要手动加入依赖--><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-bootstrap</artifactId></dependency><!-- 引用PathMatcher匹配方法 --><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency></dependencies>
2)在resources下配置文件bootstrap.yml(注意:是bootstrap.yml,而非application.yml)
server:port: 9001
spring:application:name: gateway-lesson20cloud:nacos:discovery:server-addr: 127.0.0.1:8848username: nacospassword: nacosconfig:server-addr: 127.0.0.1:8848username: nacospassword: nacosloadbalancer:ribbon:enabled: false#redis配置data:redis:host: 127.0.0.1
3)拷贝lesson06的resources目录下的demo.jks文件到gateway子模块的resources下面
4.2 配置nacos自动加载
1)nacos上面配置gateway-lesson20的json格式配置文件,在public命名空间下,DEFAULT_GROUP分组下面
2)gateway-lesson20的内容如下
[{"id": "auth","predicates": [{"args": {"pattern": "/auth/**"},"name": "Path"}],"uri": "http://localhost:9000","filters": [{"args": {"_genkey_0": "1"},"name": "StripPrefix"}]},{"id": "api-a","predicates": [{"args": {"pattern": "/api-a/**"},"name": "Path"}],"uri": "http://localhost:9002","filters": [{"args": {"_genkey_0": "1"},"name": "StripPrefix"}]}
]
3)在config包下面配置nacos自动加载代码NacosRouteDefinitionRepository
/*** nacos路由数据源*/
@Component
public class NacosRouteDefinitionRepository implements RouteDefinitionRepository {private static final Logger logger = LoggerFactory.getLogger(NacosRouteDefinitionRepository.class);private static final String SCG_DATA_ID = "gateway-lesson20";private static final String SCG_GROUP_ID = "DEFAULT_GROUP";private final ApplicationEventPublisher publisher;private final NacosConfigManager nacosConfigManager;public NacosRouteDefinitionRepository(ApplicationEventPublisher publisher, NacosConfigManager nacosConfigManager) {this.publisher = publisher;this.nacosConfigManager = nacosConfigManager;addListener();}@Overridepublic Flux<RouteDefinition> getRouteDefinitions() {List<RouteDefinition> routeDefinitionList = new ArrayList<>(0);try {String configContent = nacosConfigManager.getConfigService().getConfig(SCG_DATA_ID, SCG_GROUP_ID, 5000);if (!Strings.isNullOrEmpty(configContent)) {routeDefinitionList = JSON.parseArray(configContent, RouteDefinition.class);}} catch (NacosException e) {logger.error("从Nacos加载配置的动态路由信息异常", e);}return Flux.fromIterable(routeDefinitionList);}/*** 添加Nacos监听*/private void addListener() {try {nacosConfigManager.getConfigService().addListener(SCG_DATA_ID, SCG_GROUP_ID, new Listener() {@Overridepublic Executor getExecutor() {return null;}@Overridepublic void receiveConfigInfo(String configInfo) {publisher.publishEvent(new RefreshRoutesEvent(this));}});} catch (NacosException e) {logger.error("添加Nacos监听异常", e);}}@Overridepublic Mono<Void> save(Mono<RouteDefinition> route) {return null;}@Overridepublic Mono<Void> delete(Mono<String> routeId) {return null;}
}
4.3 配置redis
1)在config包下面配置redis配置类RedisConfiguration
@Configuration
public class RedisConfiguration {/*** 主要做redis配置。redis有2种不同的template(2种的key不能共享)* 1.StringRedisTemplate:以String作为存储方式:默认使用StringRedisTemplate,其value都是以String方式存储* 2.RedisTemplate:* 1)使用默认RedisTemplate时,其value都是根据jdk序列化的方式存储* 2)自定义Jackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是LinkedHashMap* 3)自定义GenericJackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是原先对象(因为保存了classname)*/@Bean@ConditionalOnMissingBean({RedisTemplate.class})public RedisTemplate redisTemplate(RedisConnectionFactory factory) {RedisTemplate<String, Object> template = new RedisTemplate();template.setConnectionFactory(factory);//本实例采用GenericJackson2JsonRedisSerializerObjectMapper om = new ObjectMapper();om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();template.setKeySerializer(stringRedisSerializer);template.setHashKeySerializer(stringRedisSerializer);template.setValueSerializer(jackson2JsonRedisSerializer);template.setHashValueSerializer(jackson2JsonRedisSerializer);template.afterPropertiesSet();return template;}@Bean@ConditionalOnMissingBean({StringRedisTemplate.class})public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {StringRedisTemplate template = new StringRedisTemplate();template.setConnectionFactory(factory);return template;}}
2)在controller下面新建RedisController,用于初始化redis数据
@RestController
public class RedisController {@Autowiredprivate RedisTemplate redisTemplate;@GetMapping("/init")public void init() {Map<String, List<String>> urlPermRolesRules = new ConcurrentHashMap<>();List<String> roles = new ArrayList<>();// /api-a/*的所有接口都需要api-a的角色才能访问roles.add("api-a");urlPermRolesRules.put("/api-a/*", roles);redisTemplate.opsForHash().putAll(ResourceServerManager.AUTHORITIES_URL_ROLE,urlPermRolesRules);}
}
4.4 配置gateway以及资源服务器
1)在包security下,新建以反应式方式设置鉴权类ResourceServerManager
@Component
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> {private static final String AUTHORIZATION_KEY ="Authorization";private static final String AUTHORITIES_SCOPE_PREFIX = "SCOPE_";public static final String AUTHORITIES_URL_ROLE = "AUTHORITIES_URL_ROLE";@Autowiredprivate RedisTemplate redisTemplate;@Overridepublic Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {ServerHttpRequest request = authorizationContext.getExchange().getRequest();// 预检请求放行if (request.getMethod() == HttpMethod.OPTIONS) {return Mono.just(new AuthorizationDecision(true));}// 访问登录和授权的请求,放行PathMatcher pathMatcher = new AntPathMatcher();String path = request.getURI().getPath();if (pathMatcher.match("/auth/**", path)|| pathMatcher.match("/init", path)) {return Mono.just(new AuthorizationDecision(true));}// 判断token(必须在Authorization中有)String token = "";boolean tokenCheckFlag = false;if(Objects.nonNull(request.getHeaders().getFirst(AUTHORIZATION_KEY)) && !Strings.isNullOrEmpty(request.getHeaders().getFirst(AUTHORIZATION_KEY))){token = request.getHeaders().getFirst(AUTHORIZATION_KEY);tokenCheckFlag = true;}if (!tokenCheckFlag) {return Mono.just(new AuthorizationDecision(false));}log.info("判断权限");// 缓存中获取url与角色的对应关系Map<String, List<String>> urlPermRolesRules = redisTemplate.opsForHash().entries(AUTHORITIES_URL_ROLE);// 获取当前资源 所需要的角色List<String> authorizedRoles = new ArrayList<>(); // 拥有访问权限的角色for (Map.Entry<String, List<String>> permRoles : urlPermRolesRules.entrySet()) {String perm = permRoles.getKey();if (pathMatcher.match(perm, path)) {List<String> values = permRoles.getValue();authorizedRoles.addAll(values);}}// 判断JWT中携带的scope,对应在缓存中有/api-a/*权限的角色Mono<AuthorizationDecision> authorizationDecisionMono = mono.filter(Authentication::isAuthenticated).flatMapIterable(Authentication::getAuthorities).map(GrantedAuthority::getAuthority).any(authority -> {String roleCode = authority.substring(AUTHORITIES_SCOPE_PREFIX.length()); // 用户的角色boolean hasAuthorized = !CollectionUtils.isEmpty(authorizedRoles) && authorizedRoles.contains(roleCode);return hasAuthorized;}).map(AuthorizationDecision::new).defaultIfEmpty(new AuthorizationDecision(false));return authorizationDecisionMono;}
}
2)在config包下,新建ResourceServerConfig,配置网关为资源服务器
@RequiredArgsConstructor
@Configuration
@EnableWebFluxSecurity //注意该注解,是使用Flux方式
public class ResourceServerConfig {private final ResourceServerManager resourceServerManager;@Beanpublic SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {// 本地获取公钥http.oauth2ResourceServer(server ->server.jwt(jwt->jwt.jwtDecoder(jwtDecoder())));// 配置路由拦截http.authorizeExchange(exchange->// 设置resourceServerManagerexchange.anyExchange().access(resourceServerManager))// 处理未授权.exceptionHandling(ex->ex.accessDeniedHandler(accessDeniedHandler()))// 关闭csrf.csrf(ServerHttpSecurity.CsrfSpec::disable);return http.build();}/*** 自定义未授权响应*/@BeanServerAccessDeniedHandler accessDeniedHandler() {return (exchange, denied) -> {Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse())).flatMap(response -> ResponseUtils.writeErrorInfo(response));return mono;};}/*** 自定义JwtDecoder*/public ReactiveJwtDecoder jwtDecoder() {NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(generateRsaKey()).build();return jwtDecoder;}/*** 其 key 在启动时生成,用于创建上述 JWKSource*/private static RSAPublicKey generateRsaKey() {KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("demo.jks"), "linmoo".toCharArray());KeyPair keyPair = factory.getKeyPair("demo", "linmoo".toCharArray());RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();return publicKey;}
}
3)在utils包下面,新建ResponseUtils工具类
public class ResponseUtils {public static Mono<Void> writeErrorInfo(ServerHttpResponse response) {response.setStatusCode(HttpStatus.UNAUTHORIZED);response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);response.getHeaders().set("Access-Control-Allow-Origin", "*");response.getHeaders().set("Cache-Control", "no-cache");String body = "{\\\"code\\\":\\\"\"" + "0001" + '\"' + "\", \\\"msg\\\":\\\"\"" + "无权限" + '\"' + "'}";DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));return response.writeWith(Mono.just(buffer)).doOnError(error -> DataBufferUtils.release(buffer));}}
4)设置启动类
@SpringBootApplication
@EnableDiscoveryClient
public class Oauth2Lesson20GatewayApplication {public static void main(String[] args) {SpringApplication.run(Oauth2Lesson20GatewayApplication .class, args);}}
5 测试服务
使用一个访问api-a的服务,作为测试后端服务
代码参考lesson20子模块下面的api-a子模块
1)在lesson20子模块下面新建api-a子模块,其pom引入如下:
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
</dependencies>
2)其resources下的yaml如下:
server:port: 9002
3)在controller目录新建DemoController测试接口,我们这里也采用响应式编程方式。
@RestController
public class DemoController {@GetMapping("/demo")public Mono<String> demo() {return Mono.just("demo");}
}
4)新建启动类Oauth2Lesson20ApiApplication
@SpringBootApplication
public class Oauth2Lesson20ApiApplication {public static void main(String[] args) {SpringApplication.run(Oauth2Lesson20ApiApplication .class, args);}}
6 测试结果
分别启动:lesson06子模块,gateway子模块和api-a子模块
1)先访问:http://localhost:9001/init 将权限和角色绑定设置到redis中
2)访问:http://localhost:9001/auth/login 进行登录,得到登录的token(注意:这里访问的是gateway,通过gateway转发到授权服务器上)
3)访问:http://localhost:9001/auth/oauth2/token 获取access_token。在步骤1中获得到的登录token,放入headers中的access_token,这里使用的是客户端模式。(注意:这里访问的是gateway,通过gateway转发到授权服务器上)
4)访问:http://localhost:9001/api-a/demo 在步骤2中获取到的的access_token,访问可以得到demo结果(注意这里也是访问gateway,由gateway判断token有效性以及鉴权,然后转发到api-a服务上)
4)假如在步骤2中的scope参数没有api-a,通过这样获取的access_token是无法访问demo的,大家可以测试一下
结语:至此,我们对Spring Security 6实现OAuth2的内容就介绍完了,当然还有很多内容没有介绍,不过相信通过这一系列,你应该已经掌握了底层原理了,自己就可以去看其它的功能。感谢陪伴!