学习笔记—微服务—技术栈实践(7)——微服务的权限方案

微服务的权限方案

  应用拆分为微服务后,一个不可避免地问题就是权限问题,拆分后地各个微服务如何权限才能保证业务地正常进行,才能保证架构地简单、可维护,都是很重要地问题。

  对于权限来说,有三个重要的词,一个是认证、一个是鉴权、一个是授权。

  认证,就是说,当进入一个系统,比如输入账户密码后,后端登陆所做的事情,校验你的用户名、密码是否正确,是否能够登陆系统,这就是认证。

  鉴权,则是不同的人登陆之后,所拥有的权限应该是不一样的,在进行操作,也就是前端URI发过来后,检验这个用户是否有操作这样一个资源的权限,就是鉴权。

  授权就是授予用户或角色资源操作的权限,也包括第三方系统的授权,于此暂按不表。

  对于一个微服务架构的应用来说,有着一个统一的入口网关,在实现认证授权上,和单机架构其实在具体的方式上没有区别,在鉴权上有一定区别。

  鉴权在微服务中已经不集中在单体应用中了,被分散在了各个微服务中,因此,需要由网关来实现。

  于此,介绍一种包含Spring Security, OAuth2.0, JWT, Gateway, JPA, Redis实现的微服务权限方案。

微服务权限方案的实现

权限相关微服务的设计

  权限相关微服务包括三个微服务,即网关服务(gateway-service)、用户鉴权服务(user-auth-service)和用户管理服务(user-management-service)

  网关服务(gateway-service)负责接收前端请求,进行认证和鉴权,将请求转发到具体的微服务中。

  用户鉴权服务(user-auth-service)负责处理用户的认证请求,包括用户名密码登录、以及可以进一步实现的如短信验证码登录、第三方登录等。

  用户管理服务(user-management-service)负责处理用户的注册、修改、删除等操作,以及用户的角色和权限管理。

RBAC数据库设计

  RBAC(Role-Based Access Control)基于角色的访问控制,是一种基于用户在系统中的角色来控制对系统资源和数据访问权限的方法。

  RBAC模型中,用户、角色、权限、资源之间的关系如下:

RBAC数据库模型
  通过为不同的用户分配不同的角色和权限,可以限制不同等级用户对微服务系统内资源的访问和操作,防止未授权的访问和操作,保护整个系统的安全性和可靠性。同时,基于 RBAC 模型设计的用户权限数据库也提供了灵活的权限管理机制,可以根据需要对用户的角色和权限进行调整和扩展,满足不同用户的需求。

鉴权认证流程

鉴权认证流程
  在这样一个方案中,设计并实现了gateway模块、user-auth模块及user-management模块来实现鉴权认证机制,具体实现如下:

  用户在表现层应用程序携带用户名密码请求认证后,从系统的唯一入口gateway模块进入,该模块利用Spring Security框架来处理安全约束,并决定是否允许请求继续。如果请求被允许,gateway模块会将请求路由到user-auth模块。user-auth模块整合了OAuth2认证服务器并可以获取用户信息。

  通过OAuth2所提供的认证服务器认证返回两个token,并在配置文件中,创建JwtAccessTokenConverter将OAuth2生成的acess-token与refresh-token转化为JWT并向acess-token中加入自定义的用户信息,user-auth模块会返回并让用户获取到OAuth2对应的JWT令牌,而这个 JWT 令牌包含了用户基本信息,用户在操作前端显示层时候,只需要携带JWT访问Gateway模块中实现的资源服务器,资源服务器就会通过事先约定好的算法将JWT中的信息进行解析,便可直接对 JWT 令牌进行校验,不需要每次都远程请求认证服务器来完成授权。

  JWT会和由user-management-service所缓存在redis中的权限进行校验,来确认鉴权的需求。

  为了保证安全性,JWT这种获取token的方式是要加密的,使用了非对称加密方式对JWT做加密操作,因为非对称加密几乎不可能被破解。将私钥存在认证中心的资源目录下,也就是user-auth模块,公钥存在各资源服务器上(于此由接口直接从user-auth模块获得公钥)。在JWT的生成构建上,通过JDK自带的keytool工具生成了RSA非对称密钥文件,并从中提取公钥。

  OAuth2用于实现表现层应用程序的安全授权,确保只有经过授权并确定该用户所处角色拥有该权限的应用程序才能访问对应的资源。网关除了网关本身的作用外,还被标记为资源服务器,由于网关使用的是webflux异步非阻塞原理实现,底层服务器基于netty不向下兼容,不兼容普通web相关,因此网关单独实现了一套资源服务认证,对token进行校验。

  在使用时,通过携带acess_token访问gateway模块,gateway模块中实现的网关过滤器与实现的资源服务器配置就会对JWT进行转换,完成权限验证,若acess_token过期,还可通过refresh_token更新token。

user-management-service的具体设计实现

  user-management-service作为一个用户管理的服务,比较简单,需要考虑的是对于权限的一个缓存。

  对于不同的业务场景,其实权限的缓存时机是比较弹性的,比如对于一个七八百年不用改权限角色的一个业务场景下,用一个比较简单的定时任务来更新redis上的角色与权限倒也就足以了。比如:

1
2
3
4
5
6
7
@Scheduled(fixedDelay = 600000) // 上一次任务执行完后每10分钟执行一次
public void performTask() {
// 这里写你需要定时执行的任务
System.out.println("执行redis的定时任务");
redisUpdateService.loadRolePermissionToRedis();
System.out.println("更新成功");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void loadRolePermissionToRedis() {
redisTemplate.delete(RedisConstant.RESOURCE_ROLES_MAP);
resourceRolesMap = new TreeMap<>();
List<SysRoleEntity> sysRoleEntities = sysRoleRepo.findAll();
for(SysRoleEntity sr: sysRoleEntities) {
//权限标识
List<SysRolePermissionEntity> sysRolePermissionEntities = sysRolePermissionRepo.findByRoleId(sr.getId());
for(SysRolePermissionEntity srp: sysRolePermissionEntities) {
SysPermissionEntity sysPermissionEntity = sysPermissionRepo.findById(srp.getPermissionId()).get();
String url = sysPermissionEntity.getUrl();
// 如果resourceRolesMap中已经包含此url,则将新的角色名添加到列表中
if(resourceRolesMap.containsKey(url)) {
resourceRolesMap.get(url).add(sr.getName());
} else {
// 否则创建一个新的列表
resourceRolesMap.put(url, new ArrayList<>(Collections.singletonList(sr.getName())));
}
}
}
redisTemplate.opsForHash().putAll(RedisConstant.RESOURCE_ROLES_MAP, resourceRolesMap);
}

  但是对于一些比较频繁的场景来说,便可以在用户的每次登陆时,进行一次权限的缓存,并考虑做无感的缓存刷新,以及一些更好的办法,于此暂按不表。

  对于现在的权限来说,构建一个resourceRolesMap,将权限与对应的角色缓存进redis中。

user-auth-service的具体设计实现

  user-auth-service是鉴权认证的一个关键微服务。采用oauth2.0。

  OAuth2.0是一个可以集成入Spring Security的开放标准,前端表现层通过得到用户的授权,获得令牌,并通过提供一个令牌而不是用户名和密码来访问存储在特定服务提供者的数据。

  在实现上,首先需先实现一个抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public abstract class AbstractUserDetailsService implements UserDetailsService {

@Resource
private SysPermissionService sysPermissionService;

@Resource
private RedisTemplate<String,Object> redisTemplate;

private Map<String, List<String>> resourceRolesMap;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 直接提供给User permission
SysUserEntity sysUserEntity = findSysUser(username);
// 一种是直接提供给User permission
// findSysPermission(sysUserEntity);
// 但这里选择提供roles并将permissions store进redis中
findSysRoles(sysUserEntity);
return sysUserEntity;
}

abstract SysUserEntity findSysUser(String username);

// 在RBAC模型下直接用权限信息作为权限列表
public void findSysPermission(SysUserEntity sysUserEntity) throws UsernameNotFoundException {
if(sysUserEntity == null) {
throw new UsernameNotFoundException("抱歉,未查询到有效用户信息");
}
List<SysPermissionEntity> sysPermissions = sysPermissionService.findByUserId(sysUserEntity.getId());
// 用户没有权限
if(CollectionUtils.isEmpty(sysPermissions)) {
return;
}
// 给用户存入权限,认证通过后用于渲染左侧菜单
sysUserEntity.setPermissions(sysPermissions);
// 封装用户信息和权限信息
List<GrantedAuthority> authorities = new ArrayList<>();
for(SysPermissionEntity sp: sysPermissions) {
//权限标识
authorities.add(new SimpleGrantedAuthority(sp.getCode()));
}
sysUserEntity.setAuthorities(authorities);
}

// 以用户的角色信息作为authorities放入JWT,并将权限信息store进redis中方便查询
public void findSysRoles(SysUserEntity sysUserEntity) throws UsernameNotFoundException {
if(sysUserEntity == null) {
throw new UsernameNotFoundException("抱歉,未查询到有效用户信息");
}
List<SysRoleEntity> sysRoleEntities = sysUserEntity.getRoleList();
// 用户没有角色
if(CollectionUtils.isEmpty(sysRoleEntities)) {
return;
}
// 封装用户信息和权限信息
List<GrantedAuthority> authorities = new ArrayList<>();
resourceRolesMap = new TreeMap<>();
for(SysRoleEntity sr: sysRoleEntities) {
//权限标识
authorities.add(new SimpleGrantedAuthority(sr.getName()));
List<SysPermissionEntity> sysPermissions = sysPermissionService.findByRoleId(sr.getId());
for (SysPermissionEntity sysPermissionEntity: sysPermissions) {
resourceRolesMap.put(sysPermissionEntity.getUrl(), CollUtil.toList(sr.getName()));
}
}
redisTemplate.opsForHash().putAll(RedisConstant.RESOURCE_ROLES_MAP, resourceRolesMap);
sysUserEntity.setAuthorities(authorities);
}
}

  在这样的一个类中,用于加载用户详细信息和权限数据,具体来说,这个类实现了一个Spring Security的一个核心接口UserDetailsService。在用户登陆时,根据用户名加载用户的详细信息并将用户的权限信息处理好。

  接着配置好OAuth2.0的认证服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

@Resource
private PasswordEncoder passwordEncoder;

@Resource
private AuthenticationManager authenticationManager;

@Resource
private CustomUserDetailsService userService;

@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;

@Resource
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Resource
private JwtTokenEnhancer jwtTokenEnhancer;


// 配置使用密码模式
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> delegates = new ArrayList<>();
//配置JWT的内容增强器
delegates.add(jwtTokenEnhancer);
delegates.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(delegates);
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userService)
//配置令牌存储策略
.tokenStore(tokenStore)
.accessTokenConverter(jwtAccessTokenConverter)
.tokenEnhancer(enhancerChain);
}

// oauth2就不用数据库了,采用memory模式
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client_id")
.secret(passwordEncoder.encode("client_password"))
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(864000)
.autoApprove(true) //自动授权配置
.scopes("all")
.authorizedGrantTypes("authorization_code","password","refresh_token");
}

// 获取密钥需要进行身份认证
// 在使用单点登录时必须配置
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients();
security.checkTokenAccess("permitAll()");

}
}

  @EnableAuthorizationServer启用Spring Security OAuth2的授权服务器支持,此处采用密码模式并通过PasswordEncoder对密码进行加密。

  此外,在configure(AuthorizationServerEndpointsConfigurer endpoints)中还配置了授权服务器的端点,包括令牌存储、用户服务、认证管理器以及令牌增强链条:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class JwtTokenEnhancer implements TokenEnhancer{
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
// 给JWT添加额外信息
// 比如以下为添加用户信息的内容
Map<String, Object> info = new HashMap<>();
SysUserEntity user = (SysUserEntity) authentication.getPrincipal();
System.out.println("user:" + user);
info.put("user_id", user.getId());
info.put("userName", user.getNickName());
return accessToken;
}
}

  (注:对于令牌增强链来说,可以添加一些生成的JWT的额外的一些信息。)

  由此,当用户调用/oauth/token接口的情况下,便可以获取到一个由用户基本信息和用户所拥有的角色列表组成的一个JWT用于后续的权限校验。

gateway-service的具体设计实现

  对于gateway-service来说,主要要靠过滤器来对内容进行实现。首先,先要实现一个鉴权管理器,用于判断是否有访问资源的权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Component
@AllArgsConstructor
@Slf4j
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

@Resource
private RedisTemplate<String,Object> redisTemplate;

@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
// 首先先获得请求路径对应的path
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getURI().getPath();
PathMatcher pathMatcher = new AntPathMatcher();
// 对应跨域的预检请求直接放行
if (request.getMethod() == HttpMethod.OPTIONS) {
return Mono.just(new AuthorizationDecision(true));
}
// 当请求的token为空拒绝访问
String token = request.getHeaders().getFirst("Authorization");
if (StrUtil.isBlank(token)) {
return Mono.just(new AuthorizationDecision(false));
}
// 随后从缓存获取user-auth-service中所缓存的资源权限角色关系列表
Map<Object, Object> resourceRolesMap = redisTemplate.opsForHash().entries(RedisConstant.RESOURCE_ROLES_MAP);
Iterator<Object> iterator = resourceRolesMap.keySet().iterator();
// 请求路径匹配到的资源需要的角色权限集合authorities
List<String> authorities = new ArrayList<>();
while (iterator.hasNext()) {
String pattern = (String) iterator.next();
if (pathMatcher.match(pattern, path)) {
authorities.addAll(Convert.toList(String.class, resourceRolesMap.get(pattern)));
}
}
// 此处暂不采用mono了,直接解析
// 给一个正确的返回和一个错误的返回
Mono<AuthorizationDecision> authorizationDecisionMonoTrue = Mono.just(new AuthorizationDecision(true));
Mono<AuthorizationDecision> authorizationDecisionMonoFalse = Mono.just(new AuthorizationDecision(false));
// 从token中解析用户信息并设置到Header中去
// 将所携带的token头GaGaDuck先去掉
String realToken = token.replace("GaGaDuck: ", "");
try {
// 解析token
JWSObject jwsObject = JWSObject.parse(realToken);
String payLoadStr = jwsObject.getPayload().toString();
// 使用 Jackson 解析 JSON
ObjectMapper objectMapper = new ObjectMapper();
JsonNode userJson = objectMapper.readTree(payLoadStr);
// 提取 authorities的列表
List<String> authoritiesJwt = new ArrayList<>();
JsonNode authoritiesNode = userJson.get("authorities");
if (authoritiesNode != null && authoritiesNode.isArray()) {
for (JsonNode authorityNode : authoritiesNode) {
// 从JWT中解析出来有哪些角色
// 将角色加入到authoritiesJwt
String authority = authorityNode.asText();
authoritiesJwt.add(authority);
}
}
// 将该角色列表的每个角色与该路由所拥有权限的角色相对比
for(String authority : authoritiesJwt) {
if(authorities.contains(authority)) {
// 如果有的话,权限校验通过,可以访问该路径
return authorizationDecisionMonoTrue;
}
}
// 没有的话就不能访问该路径
return authorizationDecisionMonoFalse;
} catch (ParseException | JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}

  对于该鉴权管理器来说,首先是要获得请求路径对应的path,在此基础上,对应跨域的预检请求直接放行(这是为了直接在网关做菜单权限的校验)。

  当请求携带的token是空的时候要拒绝访问,随后从缓存中拉取user-management-service所缓存的资源权限角色关系列表。将token进行解析,并且与缓存得到的权限进行对比,对于JWT解析出来的角色,将该角色与拥有该权限的角色进行对比,来判断是否有该权限。

  简单来说,JWT中有用户的角色信息,从缓存中获得了该角色的权限信息,当一个URI来了,如果这个URI和这个角色所应该拥有的权限不一样,那么就不能访问这个路径,如果一样,则可以访问这样一个路径。

  对于菜单权限的过滤也是同理,于此不再赘述。

  等过滤器设置好后,可对资源服务器类进行配置。通过鉴权管理器配置的放行权限名单进行放行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
.authenticationEntryPoint(restAuthenticationEntryPoint)
);
// 增加过滤器过滤忽略的白名单Url
http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION)
// 增加过滤器过滤菜单校验的请求
.addFilterBefore(menuFilter, SecurityWebFiltersOrder.AUTHORIZATION)
.authorizeExchange(exchange -> exchange
// 直接在properties中配置的白名单
.pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(),String.class)).permitAll()
// 通过鉴权管理器配置的放行权限名单
.anyExchange().access(authorizationManager))
.exceptionHandling(exceptionHandling -> exceptionHandling
// 返回未授权处理的信息
.accessDeniedHandler(restfulAccessDeniedHandler)
// 返回未未认证处理
.authenticationEntryPoint(restAuthenticationEntryPoint))
// 禁用 CSRF
.csrf(ServerHttpSecurity.CsrfSpec::disable);
return http.build();
}

  此外,还需要将用户的认证接口放入白名单中直接通行,避免因为没有token而使得无法认证。

  由此,完成鉴权认证,具体的实现参见https://github.com/gagaducko/springcloud-microservice-security


学习笔记—微服务—技术栈实践(7)——微服务的权限方案
https://gagaducko.github.io/2024/09/14/学习笔记—微服务—技术栈实践-7-微服务的权限方案/
作者
gagaduck
发布于
2024年9月14日
许可协议