一. 前言
二. OAuth2和JWT的关系
1. 什么是OAuth2?
OAUth2就是一套广泛流行的认证授权协议,大白话说呢OAuth2这套协议中有两个核心的角色,认证服务器和资源服务器。
两个角色和 youlai-mall 模块对应关系如下:
模块名称 | youlai-mall模块 | OAuth2角色 | 服务地址 |
认证中心 | youlai-auth | 认证服务器 | localhost:8000 |
网关 | youlai-gateway | 资源服务器 | localhost:9999 |
用户不能直接去访问资源服务器(网关),必须先到认证服务器认证,通过后颁发一个token令牌给你,你只有拿着token访问资源服务器才能通过,令牌token是有时间限制的,到时间了就无效。
这个模式相信经常到甲方爸爸的地方做驻场的小伙伴深有体会,一般人家可不会给你一个正式员工工牌,要么拿身份证抵押换个临时访问牌,隔天就失效,这样人家才有安全感嘛~
其中网关为什么能作为“资源服务器”呢? 网关是作为各个微服务(会员服务、商品服务、订单服务等)统一入口,也就是这些资源服务的统一门面,在这里可以对JWT验签、JWT有效期判断、JWT携带角色权限判断。
2. 什么是JWT?
JWT(JSON Web Token)它没啥悬乎的,就是一个特殊的token,最大的特性就是无状态,因为它本身可以携带用户的信息(用户ID、用户名、用户的角色集合等),我们先看下一个解析过后的JWT是什么样子的。
JWT字符串由Header(头部)、Payload(负载)、Signature(签名)三部分组成。
复制代码12345LANGUAGE-MAKEFILEHeader: JSON对象,用来描述JWT的元数据,alg属性表示签名的算法,typ标识token的类型
Payload: JSON对象,重要部分,除了默认的字段,还可以扩展自定义字段,比如用户ID、姓名、角色等等
Signature: 对Header、Payload这两部分进行签名,认证服务器使用私钥签名,然后在资源服务器使用公钥验签,防止数据被人动了手脚
JWT和传统的Cookie/Session会话管理相比较有着多方面的优势,因为Cookie/Session需要在服务器Session存用户信息,然后拿客户端Cookie存储的SessionId获取用户信息,这个过程需要消耗服务器的内存和对客户端的要求比较严格(需支持Cookie),而JWT最大的特性在于就是无状态、去中心化,所以JWT更适用分布式的场景,不需要在多台服务器做会话同步这种消耗服务器性能的操作。
另外JWT和Redis+Token这两种会话管理小伙伴们看项目情况选择,别有用了JWT还使用Redis存储的,因为你这种做法对JWT来说就是“伤害不大,但侮辱性极强”的做法,就当着它的面说我就看不上你的最自以为是的“无状态”特性。
3. OAuth2和JWT关系?
- OAuth2是一种认证授权的协议规范。
- JWT是基于token的安全认证协议的实现。
OAuth2的认证服务器签发的token可以使用JWT实现,JWT轻量且安全。
三. 认证服务器
认证服务器落地 youlai-mall 的youlai-auth认证中心模块,完整代码地址: Gitee| Github
1. pom依赖
复制代码123456789LANGUAGE-XML <dependency>
<groupId>org.;/groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.;/groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
2. 认证服务配置(AuthorizationServerConfig)
复制代码1234567891011128192021222324252627282930337383940414243444546474849505575859606162636465666768697071727374757677787980887888990JAVA/**
* 认证服务配置
*/
@Configuration
@EnableAuthorizationServer
@AllArgsConstructor
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
private DataSource dataSource;
private AuthenticationManager authenticationManager;
private UserDetailsServiceImpl userDetailsService;
/**
* 客户端信息配置
*/
@Override
@SneakyThrows
public void configure(ClientDetailsServiceConfigurer clients) {
jdbcClientDetailsServiceImpl jdbcClientDetailsService = new JdbcClientDetailsServiceImpl(dataSource);
jdbcClien);
jdbcClien);
clien(jdbcClientDetailsService);
}
/**
* 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> tokenEnhancers = new ArrayList<>();
(tokenEnhancer());
(jwtAccessTokenConverter());
(tokenEnhancers);
end(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain)
.userDetailsService(userDetailsService)
// refresh_token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
// 1.重复使用:access_token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
// 2.非重复使用:access_token过期刷新时, refresh_token过期时间延续,在refresh_token有效期内刷新而无需失效再次登录
.reuseRefreshTokens(false);
}
/**
* 允许表单认证
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
();
}
/**
* 使用非对称加密算法对token签名
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
conver(keyPair());
return converter;
}
/**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
new ClassPathResource("youlai.jks"), "123456".toCharArray());
KeyPair keyPair = (
"youlai", "123456".toCharArray());
return keyPair;
}
/**
* JWT内容增强
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
Map<String, Object> map = new HashMap<>(2);
User user = (User) au().getPrincipal();
map.pu, u());
map.pu, u());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(map);
return accessToken;
};
}
}
AuthorizationServerConfig这个配置类是整个认证服务实现的核心。总结下来就是两个关键点,客户端信息配置和access_token生成配置。
2.1 客户端信息配置
配置OAuth2认证允许接入的客户端的信息,因为接入OAuth2认证服务器首先人家得认可你这个客户端吧,就比如上面案例中的QQ的OAuth2认证服务器认可“有道云笔记”客户端。
同理,我们需要把客户端信息配置在认证服务器上来表示认证服务器所认可的客户端。一般可配置在认证服务器的内存中,但是这样很不方便管理扩展。所以实际最好配置在数据库中的,提供可视化界面对其进行管理,方便以后像PC端、APP端、小程序端等多端灵活接入。
Spring Security OAuth2官方提供的客户端信息表oauth_client_details
复制代码1234567891011121314SQLCREATE TABLE `oauth_client_details` (
`client_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`access_token_validity` int(11) NULL DEFAULT NULL,
`refresh_token_validity` int(11) NULL DEFAULT NULL,
`additional_information` varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
添加一条客户端信息
复制代码1SQLINSERT INTO `oauth_client_details` VALUES ('client', NULL, '123456', 'all', 'password,refresh_token', '', NULL, NULL, NULL, NULL, NULL);
2.2 token生成配置
项目使用JWT实现access_token,关于access_token生成步骤的配置如下:
1. 生成密钥库
使用JDK工具的keytool生成JKS密钥库(Java Key Store),并将youlai.jks放到resources目录
keytool -genkey -alias youlai -keyalg RSA -keypass 123456 -keystore youlai.jks -storepass 123456
复制代码1234567891011LANGUAGE-DIFF-genkey 生成密钥
-alias 别名
-keyalg 密钥算法
-keypass 密钥口令
-keystore 生成密钥库的存储路径和名称
-storepass 密钥库口令
2. JWT内容增强
JWT负载信息默认是固定的,如果想自定义添加一些额外信息,需要实现TokenEnhancer的enhance方法将附加信息添加到access_token中。
3. JWT签名
JwtAccessTokenConverter是生成token的转换器,可以实现指定token的生成方式(JWT)和对JWT进行签名。
签名实际上是生成一段标识(JWT的Signature部分)作为接收方验证信息是否被篡改的依据。原理部分请参考这篇的文章:RSA加密、解密、签名、验签的原理及方法
其中对JWT签名有对称和非对称两种方式:
对称方式:认证服务器和资源服务器使用同一个密钥进行加签和验签 ,默认算法HMAC
非对称方式:认证服务器使用私钥加签,资源服务器使用公钥验签,默认算法RSA
非对称方式相较于对称方式更为安全,因为私钥只有认证服务器知道。
项目中使用RSA非对称签名方式,具体实现步骤如下:
复制代码123LANGUAGE-SCSS(1). 从密钥库获取密钥对(密钥+私钥)
(2). 认证服务器私钥对token签名
(3). 提供公钥获取接口供资源服务器验签使用
公钥获取接口
复制代码1234567891011121314151617LANGUAGE-TYPESCRIPT/**
* RSA公钥开放接口
*/
@RestController
@AllArgsConstructor
public class PublicKeyController {
private KeyPair keyPair;
@GetMapping("/getPublicKey")
public Map<String, Object> getPublicKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
3. 安全配置(WebSecurityConfig)
复制代码1234567891011128192021222324252627LANGUAGE-SCSS@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizerequests().RequestMatcher()).permitAll()
.and()
.authorizeRequests().antMatchers("/getPublicKey").permitAll().anyRequest().authenticated()
.and()
.csrf().disable();
}
/**
* 如果不配置SpringBoot会自动配置一个AuthenticationManager,覆盖掉内存中的用户
*/
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return ();
}
@Bean
public PasswordEncoder passwordEncoder() {
return Pa();
}
}
安全配置主要是配置请求访问权限、定义认证管理器、密码加密配置。
四. 资源服务器
资源服务器落地 youlai-mall 的youlai-gateway微服务网关模块,完整代码地址: Gitee | Github |
上文有提到过网关这里是担任资源服务器的角色,因为网关是微服务资源访问的统一入口,所以在这里做资源访问的统一鉴权是再合适不过。
1. pom依赖
复制代码12345678XML <dependency>
<groupId>org.;/groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.;/groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
2. 配置文件)
复制代码12345678910111281920212223242526272829303YAMLspring:
security:
oauth2:
resourceserver:
jwt:
# 获取JWT验签公钥请求路径
jwk-set-uri: 'http://localhost:8000/getPublicKey'
redis:
database: 0
host: localhost
port: 6379
password:
cloud:
gateway:
discovery:
locator:
enabled: true # 启用服务发现
lower-case-service-id: true
routes:
- id: youlai-auth
uri: lb://youlai-auth
predicates:
- Path=/youlai-auth/**
filters:
- StripPrefix=1
- id: youlai-admin
uri: lb://youlai-admin
predicates:
- Path=/youlai-admin/**
filters:
- StripPrefix=1
# 配置白名单路径
white-list:
urls:
- "/youlai-auth/oauth/token"
3. 鉴权管理器
鉴权管理器是作为资源服务器验证是否有权访问资源的裁决者,核心部分的功能先已通过注释形式进行说明,后面再对具体形式补充。
复制代码1234567891011128192021222324252627282930337383940414243444546474849505JAVA/**
* 鉴权管理器
*/
@Component
@AllArgsConstructor
@Slf4j
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private RedisTemplate redisTemplate;
private WhiteListConfig whiteListConfig;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
ServerHttpRequest request = au().getRequest();
String path = reque().getPath();
PathMatcher pathMatcher = new AntPathMatcher();
// 1. 对应跨域的预检请求直接放行
if () == H) {
return Mono.just(new AuthorizationDecision(true));
}
// 2. token为空拒绝访问
String token = reque().getFir);
if (token)) {
return Mono.just(new AuthorizationDecision(false));
}
// 3.缓存取资源权限角色关系列表
Map<Object, Object> resourceRolesMap = redi().entrie);
Iterator<Object> iterator = re().iterator();
// 4.请求路径匹配到的资源需要的角色权限集合authorities
List<String> authorities = new ArrayList<>();
while ()) {
String pattern = (String) i();
if (pattern, path)) {
au, re(pattern)));
}
}
Mono<AuthorizationDecision> authorizationDecisionMono = mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(roleId -> {
// 5. roleId是请求用户的角色(格式:ROLE_{roleId}),authorities是请求资源所需要角色的集合
log.info("访问路径:{}", path);
log.info("用户角色roleId:{}", roleId);
log.info("资源需要权限authorities:{}", authorities);
return au(roleId);
})
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
return authorizationDecisionMono;
}
}
第1、2处只是做些基础访问判断,不做过多的说明
第3处从Redis缓存获取资源权限数据。首先我们会关注两个问题:
复制代码12LANGUAGE-CSSa. 资源权限数据是什么样格式数据?
b. 数据什么时候初始化到缓存中?
以下就带着这两个问题来分析要完成第4步从缓存获取资源权限数据需要提前做哪些工作吧。
a. 资源权限数据格式
需要把url和role_ids的映射关系缓存到redis,大致意思的意思可以理解拥有url访问权限的角色ID有哪些。
b. 初始化缓存时机
SpringBoot提供两个接口CommandLineRunner和ApplicationRunner用于容器启动后执行一些业务逻辑,比如数据初始化和预加载、MQ监听启动等。两个接口执行时机无差,唯一区别在于接口的参数不同。有兴趣的朋友可以了解一下这两位朋友,以后会经常再见的哈~
那么这里的业务逻辑是在容器初始化完成之后将从MySQL读取到资源权限数据加载到Redis缓存中,正中下怀,来看下具体实现吧。
Redis缓存中的资源权限数据
至此从缓存数据可以看到拥有资源url访问权限的角色信息,从缓存获取赋值给resourceRolesMap。
第4处根据请求路径去匹配resourceRolesMap的资url(Ant Path匹配规则),得到对应资源所需角色信息添加到authorities。
第5处就是判断用户是否有权访问资源的最终一步了,只要用户的角色中匹配到authorities中的任何一个,就说明该用户拥有访问权限,允许通过。
4. 资源服务器配置
这里做的工作是将鉴权管理器AuthorizationManager配置到资源服务器、请求白名单放行、无权访问和无效token的自定义异常响应。配置类基本上都是约定俗成那一套,核心功能和注意的细节点通过注释说明。
复制代码123456789101112819202122232425262728293033738394041424344454647484950LANGUAGE-TYPESCRIPT/**
* 资源服务器配置
*/
@AllArgsConstructor
@Configuration
// 注解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因为SpringCloud Gateway基于WebFlux
@EnableWebFluxSecurity
public class ResourceServerConfig {
private AuthorizationManager authorizationManager;
private CustomServerAccessDeniedHandler customServerAccessDeniedHandler;
private CustomServerAuthenticationEntryPoint customServerAuthenticationEntryPoint;
private WhiteListConfig whiteListConfig;
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
// 自定义处理JWT请求头过期或签名错误的结果
().authenticationEntryPoint(customServerAuthenticationEntryPoint);
()
.pathMatcher(),S)).permitAll()
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
.accessDeniedHandler(customServerAccessDeniedHandler) // 处理未授权
.authenticationEntryPoint(customServerAuthenticationEntryPoint) //处理未认证
.and().csrf().disable();
return ();
}
/**
* @link
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
* 需要把jwt的Claim中的authorities加入
* 方案:重新定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jw);
jw);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jw(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
复制代码1234567891011128JAVA/**
* 无权访问自定义响应
*/
@Component
public class CustomServerAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException e) {
ServerHttpResponse response=exc();
re);
re().se, MediaTy);
re().set("Access-Control-Allow-Origin","*");
re().set("Cache-Control","no-cache");
String body= JSONU));
DataBuffer buffer = re().wra("UTF-8")));
return re(buffer));
}
}
复制代码123456789101112819JAVA/**
* 无效token/token过期 自定义响应
*/
@Component
public class CustomServerAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
ServerHttpResponse response = exc();
re);
re().se, MediaTy);
re().set("Access-Control-Allow-Origin", "*");
re().set("Cache-Control", "no-cache");
String body = JSONU));
DataBuffer buffer = re().wra("UTF-8")));
return re(buffer));
}
}
5. 网关鉴权测试
模拟数据说明,admin用户拥有角色2,角色2有菜单管理、用户管理、部门管理的资源权限,无其他权限
用户 | 角色ID | 角色名称 |
admin | 2 | 系统管理员 |
资源名称 | 资源路径 | 要求角色权限 |
系统管理 | /youlai-admin/** | [1] |
菜单管理 | /youlai-admin/menus/** | [1,2] |
用户管理 | /youlai-admin/users/** | [1,2] |
部门管理 | /youlai-admin/depts/** | [1,2] |
字典管理 | /youlai-admin/dictionaries/** | [1] |
角色管理 | /youlai-admin/roles/** | [1] |
资源管理 | /youlai-admin/resources/** | [1] |
启动管理平台前端工程 youlai-mall-admin 完整代码地址:Gitee | Github |
访问除了菜单管理、用户管理、部门管理这三个系统管理员拥有访问权限的资源之外,页面都会提示“访问未授权”,直接的说明了网关服务器实现了请求鉴权的目的。
五. 结语
至此,Spring Cloud的统一认证授权就实现了。其实还有很多可以扩展的点,文章中把客户端信息存储在数据库中,那么可以添加一个管理界面来维护这些客户端信息,这样便可灵活配置客户端接入认证平台、认证有效期等等。同时也还有未完成的事项,我们知道JWT是无状态的,那用户在登出、修改密码、注销的时候怎么能把JWT置为无效呢?因为不可能像cookie/session机制把用户信息从服务器删除。所以这些都是值得思考的东西,我会在下篇文章提供对应的解决方案。
大家对文章或项目有好的建议,欢迎留言,感谢~
如果本文对你有帮助,别忘记给我个3连 ,点赞,转发,评论,咱们下期见。
本文链接: