zuul security jdbc鉴权
稍微介绍一下如何使用zuul和security在网关层实现基于jdbc的鉴权
背景
网站的后台往往会对不同的资源设置不同的权限. 这里我们所指的资源是后端的接口. 权限就是用户的身份, 普通用户, 游客, 管理员等等.
这样设计鉴权必然需要至少三张表, 用户表, 权限表, 资源表. 下面你可以执行如下的sql进行创建这三张表.
-- 创建用户信息表
CREATE TABLE `communicate_db`.`user_info`
(
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(255) NOT NULL COMMENT '密码',
`phone` VARCHAR(11) NOT NULL COMMENT '手机号',
`age` INT(4) UNSIGNED NULL DEFAULT NULL COMMENT '年龄',
`cipher` TINYINT(4) UNSIGNED NOT NULL DEFAULT 2 COMMENT '性别 0男1女2秘密',
`is_delete` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否删除 1是2否',
`create_time` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE INDEX `uk_username` (`username`),
INDEX `idx_delete` (`is_delete`)
)
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB;
-- 角色表
CREATE TABLE `communicate_db`.`role_info`
(
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`role_code` varchar(15) NULL COMMENT '角色编码',
`role_name` varchar(30) NULL COMMENT '角色名称',
`description` varchar(255) NULL COMMENT '描述',
`create_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
PRIMARY KEY (`id`)
)
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB;
-- 资源表
CREATE TABLE `communicate_db`.`permission_info`
(
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`url` varchar(255) NULL COMMENT '路径',
`method` varchar(8) NULL COMMENT '方法 GET POST PUT DELETE',
`is_private` tinyint(1) NULL COMMENT '是否可以直接访问 0不能 1能',
`create_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE INDEX `uk_url_method` (`url`, `method`)
)
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB;
有了这三张独立的表, 我们还要建立起他们之间的关系, 因此新增用户权限表, 权限资源表.
-- 用户角色表
CREATE TABLE `communicate_db`.`role_user`
(
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`role_id` int(11) NULL,
`user_id` int(11) NULL,
`create_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
PRIMARY KEY (`id`)
)
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB;
-- 角色资源表
CREATE TABLE `communicate_db`.`role_permission`
(
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`role_id` int(11) NULL,
`permission_id` int(11) NULL,
`create_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`update_time` timestamp(0) NULL DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
PRIMARY KEY (`id`)
)
COLLATE = 'utf8mb4_0900_ai_ci'
ENGINE = InnoDB;
有了以上表, 我们向其中插入数据.
-- 注意 以下角色和权限是一回事
-- 初始化的用户数据
-- nikolazhang 123123
-- 秦秀莲 123123
-- zhangxu 123123
INSERT INTO `communicate_db`.`user_info` VALUES (1, 'nikolazhang', '$2a$12$vH8agvDqrTuc0xAXY8JKhuAib.bxj6Ovgdf.IemVCR6l1ufaOI8uO', '17811112222', NULL, 2, 0, '2020-03-02 16:50:28', '2020-03-02 16:50:28');
INSERT INTO `communicate_db`.`user_info` VALUES (2, '秦秀莲', '$2a$12$vH8agvDqrTuc0xAXY8JKhuAib.bxj6Ovgdf.IemVCR6l1ufaOI8uO', '15511112222', NULL, 2, 0, '2020-03-02 16:50:28', '2020-03-02 16:50:28');
INSERT INTO `communicate_db`.`user_info` VALUES (3, 'zhangxu', '$2a$12$vH8agvDqrTuc0xAXY8JKhuAib.bxj6Ovgdf.IemVCR6l1ufaOI8uO', '15211112222', NULL, 2, 0, '2020-03-02 16:50:28', '2020-03-02 16:50:28');
-- 权限信息
INSERT INTO `communicate_db`.`role_info` VALUES (1, 'ROLE_VISIT', '游客', '最低权限 只能查看', '2020-03-02 17:51:12', '2020-03-02 17:51:12');
INSERT INTO `communicate_db`.`role_info` VALUES (2, 'ROLE_NORMAL', '一般用户', '可以进行用户本人数据操作', '2020-03-02 17:51:58', '2020-03-02 17:51:58');
INSERT INTO `communicate_db`.`role_info` VALUES (3, 'ROLE_ADMIN', '管理员', '高权限', '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`role_info` VALUES (4, 'ROLE_NIKOLA', 'NIKOLA', '顶级权限', '2020-03-02 17:52:25', '2020-03-02 17:52:25');
-- 资源信息
INSERT INTO `communicate_db`.`permission_info` VALUES (1, '/user/auth/visit', 'GET', 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`permission_info` VALUES (2, '/user/auth/normal', 'GET', 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`permission_info` VALUES (3, '/user/auth/admin', 'GET', 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`permission_info` VALUES (4, '/user/auth/nikola', 'GET', 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
-- 用户权限信息 roleId userId
-- nikolazhang拥有的角色 VISIT NORMAL ADMIN
INSERT INTO `communicate_db`.`role_user` VALUES (1, 1, 1, '2020-03-02 17:50:18', '2020-03-02 17:50:18');
INSERT INTO `communicate_db`.`role_user` VALUES (2, 2, 1, '2020-03-02 17:50:30', '2020-03-02 17:50:30');
INSERT INTO `communicate_db`.`role_user` VALUES (3, 3, 1, '2020-03-02 17:50:37', '2020-03-02 17:50:37');
-- 秦秀莲为游客权限 VISIT
INSERT INTO `communicate_db`.`role_user` VALUES (4, 1, 2, '2020-03-02 17:50:40', '2020-03-02 17:50:40');
-- zhangxu为顶级权限 NIKOLA
INSERT INTO `communicate_db`.`role_user` VALUES (5, 4, 3, '2020-03-02 17:50:40', '2020-03-02 17:50:40');
-- 资源权限信息 roleId permissionId
-- ADMIN 可以访问 visit normal admin 不能访问nikola
INSERT INTO `communicate_db`.`role_permission` VALUES (1, 3, 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`role_permission` VALUES (2, 3, 2, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`role_permission` VALUES (3, 3, 3, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
-- NORMAL 可以访问 normal visit
INSERT INTO `communicate_db`.`role_permission` VALUES (4, 2, 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
INSERT INTO `communicate_db`.`role_permission` VALUES (5, 2, 2, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
-- VISIT 只能访问 visit
INSERT INTO `communicate_db`.`role_permission` VALUES (6, 1, 1, '2020-03-02 17:52:25', '2020-03-02 17:52:25');
-- 不配置NIKOLA 由系统决定
以上数据的关系我都在注释里介绍了.
最后我们实现的效果应该是:
- nikolazhang可以访问
/user/auth/visit
,/user/auth/normal
,/user/auth/admin
. 不可以访问/user/auth/nikola
- 秦秀莲只可以访问
/user/auth/visit
- zhangxu可以访问, 这个我们没有配置, 但是想让他能访问所有的资源. 一般我们新增了一些接口之后可能不能够及时的取配置这些资源对应的权限.
为了安全起见我们在系统里设置一个顶级权限, 拥有这个权限的用户可以访问这些没有配置权限的资源.
另外, permission_info
中的is_private
这个字段在本篇中没有任何作用.
模块介绍
因为有个大计划, 所以这里我对模块可能分的有些细粒度. 由于是刚开始所以还算是清晰明了.
- zookeeper注册中心, 这是微服务必不可少的. 当然了你也可以使用eureka. 其实个人更喜欢后者. 但是听到eureka不再开源之后. 立刻就换了zookeeper.
- 网关模块, 这里我使用的还是zuul, 为什么没使用gateway呢? 因为我先写的security鉴权部分, 之后添加gateway发现不能兼容.
为了写这个文章, 我就暂时用了zuul, 以后肯定是会升级的. 同时鉴权实现也会发生变化. 这样就可以有两篇文章了. - 服务模块, 就是单纯的spring mvc而已.
- 另外通过feign调用相应模块中的资源, 因为在网关鉴权的时候, 有些资源是要从服务模块中获取的.
注册中心
这个就不说了, 因为这个和我们的文章无关.
feign
这个我们也不详述了. 当你看到代码里有****Client这种就是调用其他服务资源的就好了. 到处使用一个库, 遍地mysql connect实在是恶心. 一个模块只有对应的库, 且不交叉应该贯彻到底.
服务模块
这个很简单就是一些借口, 你只需要复制下面的就可以了. 因为这些资源路径要和数据库中对应的.
/**
* 权限测试
* @Description: AuthorizeController.java
* @Author: zhangxu
* @Createdate: 2020/3/3 18:38
*/
@Slf4j
@RestController
@RequestMapping("/user/auth")
public class AuthorizeController {
private final UserInfoRepository userInfoRepository;
@Autowired
public AuthorizeController(UserInfoRepository userInfoRepository) {
this.userInfoRepository = userInfoRepository;
}
@GetMapping("/visit")
public String visit() {
return "访客可以查看";
}
@GetMapping("/normal")
public String normal() {
return "普通用户可以查看";
}
@GetMapping("/admin")
public String admin() {
return "管理员可以查看";
}
@GetMapping("/nikola")
public String nikola() {
return "只有我nikola可以查看";
}
}
网关
搭建一个zuul, 需要以下步骤:
- 添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
这里顺便提及一下我们使用的springboot版本是2.2.4.RELEASE, 对应的springcloud版本是Hoxton.SR1.
这个很重要, 不然项目出什么问题, 你可能得费很大劲去找到合适的版本.
毕竟不想把所有代码都粘出来, 文末我会附上gitee仓库地址. 要坚持到最后啊!
2. 启动类添加@EnableZuulProxy
注解, 这个用于开启zuul的路由转发功能.
3. 配置路由转发
zuul:
sensitive-headers: "*"
routes:
syscore:
path: /user/**
stripPrefix: false
总之根据自己的实际情况配置就就好. 这里的配置的意思是, 以/user开头的所有请求都会转发到syscore这个服务模块(上文已经提及的)上去. stripPrefix=false即不去除/user.
以上一个小小的网关就搭建好了.
鉴权
现在开始介绍如何结合spring security实现鉴权.
- 首先引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
这样我们的应用在启动之后会默认生成一个账号user, 密码会打印在日志中. 当你访问资源时, 会让你进行登录. 当然这不是我们想要的. 为了实现上面说的效果我们要自定义security的一些配置.
2. 配置security
这里需要继承WebSecurityConfigurerAdapter
并重写你需要的方法
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.and()
.authorizeRequests()
.anyRequest().authenticated().withObjectPostProcessor(new MineObjectPostProcessor());
}
private class MineObjectPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {
@Override
public <O extends FilterSecurityInterceptor> O postProcess(O o) {
// 1. 访问一个url的时候返回这个url资源对应的权限
o.setSecurityMetadataSource(mineFilterInvocationSecurityMetadataSource);
// 2. 判断用户是否具有访问url资源的权限 具有权限直接返回 不具有抛出401异常
o.setAccessDecisionManager(mineAccessDecisionManager);
return o;
}
}
configure(HttpSecurity http)
是配置的重点, 这里我们配置登录方式为表单方式(security提供的登录界面), 并对所有请求进行校验, 校验时使用我们自定义的SecurityMetadataSource
和AccessDecisionManager
. 前者用于放置资源对应的权限信息. 后者用于判断用户是否具有资源的权限.
当然, 提到用户就要有一个方法去获取用户的信息:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 获取jdbc中的用户权限信息, 并指定密码校验方式
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder);
}
- 当然你可以配置一些可以完全忽略校验的请求
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().mvcMatchers("/favicon.ico");
}
- 可以看到我们的程序依赖了以下对象. 这些都是自定义的因此要注入进来
private final UserDetailServiceImpl userDetailService;
private final PasswordEncoder passwordEncoder;
private final MineFilterInvocationSecurityMetadataSource mineFilterInvocationSecurityMetadataSource;
private final MineAccessDecisionManager mineAccessDecisionManager;
@Autowired
public WebSecurityConfig(PasswordEncoder passwordEncoder, UserDetailServiceImpl userDetailService, MineFilterInvocationSecurityMetadataSource mineFilterInvocationSecurityMetadataSource, MineAccessDecisionManager mineAccessDecisionManager) {
this.passwordEncoder = passwordEncoder;
this.userDetailService = userDetailService;
this.mineFilterInvocationSecurityMetadataSource = mineFilterInvocationSecurityMetadataSource;
this.mineAccessDecisionManager = mineAccessDecisionManager;
}
我们下面详细看一下这些类或者对象的具体信息.
passwordEncoder
用于用户密码加密, 注意加密方式要和用户注册时候的相同. 否则不会match.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
userDetailService
用于存放登录用户的一些信息, 主要是用户名, 密码, 权限.
@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {
private final UserClient userClient;
public UserDetailServiceImpl(UserClient userClient) {
this.userClient = userClient;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.debug("[UserDetailServiceImpl] ====> 获取用户信息");
// 1. 获取jdbc中的用户信息 主要是用户的密码和角色
UserDO userDo = userClient.findUserByName(username);
if (Objects.isNull(userDo)) {
throw new BusinessException("只有注册之后才可以进行登录哦XD", HttpStatus.UNAUTHORIZED);
}
List<RoleDO> roleDos = userClient.findUserAuthority(userDo.getUserId());
// 2. 设置用户所具有的权限
List<GrantedAuthority> authorities = new ArrayList<>(4);
log.debug("[UserDetailServiceImpl] ====> 当前用户{}的权限为: {}", userDo.getUserId(), JSON.toJSONString(roleDos));
for (RoleDO roleDo : roleDos) {
if (StringUtils.isNotEmpty(roleDo.getRoleCode())) {
authorities.add(new SimpleGrantedAuthority(roleDo.getRoleCode()));
}
}
// 3. 返回UserDetails (通过用户名, 密码及权限生成对象)
UserDetails userDetails = new User(username, userDo.getPassword(), authorities);
return userDetails;
}
}
GrantedAuthority
是用来存放用户的权限信息的. SimpleGrantedAuthority
是他的一个实现, 用于存放用户权限(角色)字符串. 当获取时可以调用getAuthority()
.
MineFilterInvocationSecurityMetadataSource
这个类是FilterInvocationSecurityMetadataSource
的实现. 我们重写getAttributes
, 去获取当前请求在数据库中配置的访问权限信息, 具体实现方法见下:
@Override
public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
log.debug("[FilterInvocationSecurityMetadataSource] ====> 获取请求需要的权限信息");
// 1. 获取当前请求信息
FilterInvocation filterInvocation = (FilterInvocation) object;
HttpServletRequest httpServletRequest = filterInvocation.getHttpRequest();
log.debug("[FilterInvocationSecurityMetadataSource] ====> 当前请求为: {}", httpServletRequest.getRequestURI());
// 2. 根据角色权限表对当前请求进行匹配, 匹配成功则取出当前请求的访问权限
List<ConfigAttribute> configAttributes = new ArrayList<>(4);
List<PermissionRoleDO> permissionRoles = userClient.listPermissionRoles();
permissionRoles.stream()
.filter(act ->
// 根据请求路径和请求方法筛选出匹配当前请求的数据集
new AntPathRequestMatcher(act.getUrl(), act.getMethod()).matches(httpServletRequest)
)
.forEach(act ->
configAttributes.add(() -> act.getRoleCode())
);
// 3. 如果当前请求不在请求列表中或者所有匹配均失败, 则使用默认权限
if (CollectionUtils.isEmpty(configAttributes)) {
// 返回一个默认权限
return SecurityConfig.createList(PermissionRoleConstant.ROLE_NIKOLA);
} else {
// 返回匹配的结果
return configAttributes;
}
}
List<PermissionRoleDO> permissionRoles = userClient.listPermissionRoles();
这个我直接获取了数据库中所有的配置, 之后通过new AntPathRequestMatcher(act.getUrl(), act.getMethod()).matches(httpServletRequest)
和当前请求进行过滤匹配. 将符合的结果添加到configAttributes
中.
最后, 如果当前请求没有匹配成功. 我们就对当前请求给与一个默认的权限. PermissionRoleConstant.ROLE_NIKOLA
. PermissionRoleConstant是 我的一个常量类.
public final class PermissionRoleConstant {
private PermissionRoleConstant() {}
/** 接口访问的最高权限, 用于访问未配置接口, 设置默认权限 */
public static final String ROLE_NIKOLA = "ROLE_NIKOLA";
}
MineAccessDecisionManager
这个类实现自AccessDecisionManager
用于判断用户是否可以访问当前资源. 上面的两步准备都是为了这一步. 这里我们直接重写decide
方法
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
log.debug("[AccessDecisionManager] ====> 判断用户是否有权访问");
// 1. 获取当前用户的访问权限
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// 2. 获取该资源访问需要的权限, 目前系统定义角色数较少
List<String> roles = new ArrayList<>(8);
configAttributes.forEach(act -> roles.add(act.getAttribute()));
log.debug("[AccessDecisionManager] ====> 所有的权限: {}", JSON.toJSONString(roles));
// 3. 判断用户是否有访问接口权限, 有的话直接返回
for (GrantedAuthority authority : authorities) {
log.debug("[AccessDecisionManager] ====> 用户当前权限: {}", authority);
// 如果当前用户为顶级 直接允许访问 || 请求需要的权限中包含用户当前的权限 允许访问
if (authority.getAuthority().equalsIgnoreCase(PermissionRoleConstant.ROLE_NIKOLA) ||
roles.contains(authority.getAuthority())) {
log.debug("[AccessDecisionManager] ====> 用户可以访问");
return;
}
}
// 4. 没有接口访问权限直接拒绝访问
throw new AccessDeniedException("没有访问权限!");
}
我们的权限控制就一句authority.getAuthority().equalsIgnoreCase(PermissionRoleConstant.ROLE_NIKOLA) || roles.contains(authority.getAuthority())
如果用户的权限为ROLE_NIKOLA或者资源的访问权限中包含了用户当前的权限则允许访问. 没有匹配的结果则直接抛出异常, 拒绝访问.
因此如果你是顶级用户那么, 你是可以访问所有资源的. 不是的话只能访问配置相应权限的资源.
测试
登录地址为: http://localhost:18000/login
秦秀莲权限测试
使用账号秦秀莲
密码123123
登录. 并访问结果见下:
NikolaZhang权限测试
zhangxu权限测试
可以看到结果符合预期
end
项目获取方法:
https://gitee.com/NikolaZhang/communicate.git
git@gitee.com:NikolaZhang/communicate.git