一、一次权限漏洞让我差点被开除
2020年,我们的后台管理系统出了一个权限漏洞:普通运营人员通过修改URL参数,直接访问了管理员的操作页面,把一个下架的商品重新上架了。
更恐怖的是,这个漏洞存在了半年多,直到有用户投诉"已经下架的商品怎么又出来了"我们才发现。
排查发现,后端只做了菜单级别的权限控制,没有做按钮级别的权限控制,也没有做数据级别的权限控制。运维人员只要知道API路径,就能调用任何接口。
从那以后,我们对权限系统进行了彻底重构。
二、权限模型
2.1 RBAC模型
RBAC(Role-Based Access Control)基于角色的访问控制:
用户 → 角色 → 权限
RBAC0:基本模型
用户-角色-权限 三层关系
RBAC1:角色继承
管理员 → 运营 → 普通用户
上级角色继承下级角色的权限
RBAC2:角色约束
互斥角色:同一用户不能同时拥有
基数约束:角色用户数量限制
RBAC3:RBAC1 + RBAC2
2.2 ABAC模型
ABAC(Attribute-Based Access Control)基于属性的访问控制:
主体属性:用户角色、部门、职级
资源属性:资源类型、创建者、敏感级别
环境属性:时间、地点、设备
操作属性:读、写、删除
策略示例:
当 用户.部门 == 资源.部门
且 用户.职级 >= 5
且 时间.在工作时间
则 允许 读取
三、RBAC实现
3.1 数据库设计
sql
-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL,
status TINYINT DEFAULT 1,
create_time DATETIME
);
-- 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY,
role_code VARCHAR(50) NOT NULL,
role_name VARCHAR(100) NOT NULL,
parent_id BIGINT, -- 父角色(角色继承)
status TINYINT DEFAULT 1
);
-- 权限表
CREATE TABLE sys_permission (
id BIGINT PRIMARY KEY,
permission_code VARCHAR(100) NOT NULL,
permission_name VARCHAR(100) NOT NULL,
resource_type VARCHAR(20), -- MENU/BUTTON/API
resource_path VARCHAR(200),
parent_id BIGINT
);
-- 用户-角色关联
CREATE TABLE sys_user_role (
user_id BIGINT,
role_id BIGINT,
PRIMARY KEY (user_id, role_id)
);
-- 角色-权限关联
CREATE TABLE sys_role_permission (
role_id BIGINT,
permission_id BIGINT,
PRIMARY KEY (role_id, permission_id)
);
3.2 权限加载
java
/**
* 权限服务
*/
@Service
@Slf4j
public class PermissionService {
@Autowired
private SysPermissionMapper permissionMapper;
@Autowired
private SysRoleMapper roleMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加载用户权限
*/
public UserPermission loadUserPermission(Long userId) {
// 先从缓存获取
String cacheKey = "user:permission:" + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, UserPermission.class);
}
// 查询用户的角色
List<SysRole> roles = roleMapper.selectByUserId(userId);
// 查询角色的权限(含继承的权限)
Set<String> permissions = new HashSet<>();
for (SysRole role : roles) {
permissions.addAll(getRolePermissions(role.getId()));
}
UserPermission userPermission = new UserPermission();
userPermission.setUserId(userId);
userPermission.setRoles(roles.stream().map(SysRole::getRoleCode).collect(Collectors.toSet()));
userPermission.setPermissions(permissions);
// 缓存
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(userPermission),
30, TimeUnit.MINUTES
);
return userPermission;
}
/**
* 获取角色权限(含继承)
*/
private Set<String> getRolePermissions(Long roleId) {
Set<String> permissions = new HashSet<>();
// 当前角色的权限
List<SysPermission> perms = permissionMapper.selectByRoleId(roleId);
perms.forEach(p -> permissions.add(p.getPermissionCode()));
// 查找父角色(角色继承)
SysRole role = roleMapper.selectById(roleId);
if (role.getParentId() != null) {
permissions.addAll(getRolePermissions(role.getParentId()));
}
return permissions;
}
}
3.3 权限拦截
java
/**
* 权限注解
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresPermission {
String value();
Logical logical() default Logical.AND;
}
/**
* 权限拦截器
*/
@Aspect
@Component
@Slf4j
public class PermissionAspect {
@Autowired
private PermissionService permissionService;
@Around("@annotation(requiresPermission)")
public Object checkPermission(ProceedingJoinPoint joinPoint,
RequiresPermission requiresPermission) throws Throwable {
// 获取当前用户
Long userId = getCurrentUserId();
String requiredPermission = requiresPermission.value();
// 加载用户权限
UserPermission userPermission = permissionService.loadUserPermission(userId);
// 检查权限
if (!userPermission.getPermissions().contains(requiredPermission)) {
log.warn("权限不足: userId={}, required={}, has={}",
userId, requiredPermission, userPermission.getPermissions());
throw new ForbiddenException("权限不足");
}
return joinPoint.proceed();
}
}
/**
* 使用示例
*/
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@RequiresPermission("user:delete")
@DeleteMapping("/users/{id}")
public Result deleteUser(@PathVariable Long id) {
// 删除用户
return Result.success();
}
@RequiresPermission("product:audit")
@PostMapping("/products/{id}/audit")
public Result auditProduct(@PathVariable Long id) {
// 审核商品
return Result.success();
}
}
四、数据权限
4.1 数据范围控制
java
/**
* 数据权限注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
/**
* 数据范围类型
*/
DataScopeType value() default DataScopeType.SELF;
/**
* 关联的部门字段
*/
String deptField() default "dept_id";
/**
* 关联的创建人字段
*/
String creatorField() default "create_by";
}
/**
* 数据权限类型
*/
public enum DataScopeType {
ALL, // 全部数据
DEPT, // 本部门数据
DEPT_AND_SUB, // 本部门及下级部门
SELF, // 仅本人数据
CUSTOM // 自定义
}
/**
* 数据权限拦截器(MyBatis插件)
*/
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Component
@Slf4j
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取方法上的DataScope注解
DataScope dataScope = getDataScopeAnnotation(invocation);
if (dataScope == null) {
return invocation.proceed();
}
// 获取当前用户
Long userId = getCurrentUserId();
UserPermission permission = permissionService.loadUserPermission(userId);
// 构建数据范围SQL
String scopeSql = buildScopeSql(dataScope, permission);
// 修改SQL,追加数据范围条件
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
String newSql = originalSql + " AND " + scopeSql;
// 替换SQL
resetSql(ms, boundSql, newSql);
return invocation.proceed();
}
/**
* 构建数据范围SQL
*/
private String buildScopeSql(DataScope dataScope, UserPermission permission) {
switch (dataScope.value()) {
case ALL:
return "1=1";
case DEPT:
return dataScope.deptField() + " = " + permission.getDeptId();
case DEPT_AND_SUB:
return dataScope.deptField() + " IN (" + getDeptAndSubIds(permission.getDeptId()) + ")";
case SELF:
return dataScope.creatorField() + " = " + permission.getUserId();
default:
return "1=0";
}
}
}
五、OAuth2集成
java
/**
* OAuth2权限集成
*/
@Configuration
@EnableResourceServer
public class OAuth2ResourceConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/user/**").authenticated()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
}
}
/**
* JWT权限解析
*/
@Component
public class JwtPermissionConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
// 从JWT中提取权限
@SuppressWarnings("unchecked")
Map<String, Object> realmAccess = (Map<String, Object>) jwt.getClaims().get("realm_access");
if (realmAccess == null) {
return Collections.emptyList();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
}
}
六、踩坑实录
坑1:只做了前端权限控制
前端隐藏了按钮,但后端接口没有权限校验,用户可以直接调API。
解决:前后端都要做权限控制,后端是最后一道防线。
坑2:权限缓存不及时刷新
管理员修改了用户权限,但缓存没刷新,用户还是用旧权限操作。
解决:权限变更时主动清除缓存。
坑3:角色爆炸
随着业务增长,角色越来越多,管理混乱。
解决:引入ABAC,减少角色数量,用属性控制。
坑4:数据权限遗漏
只做了功能权限,没做数据权限,用户能看到别人的数据。
解决:核心业务必须做数据权限控制。
坑5:超级管理员硬编码
到处写if(user.isAdmin()),逻辑散落各处。
解决:超级管理员也是一种角色,统一走权限体系。
七、总结
权限系统设计:
| 层级 | 方案 |
|---|---|
| 功能权限 | RBAC |
| 数据权限 | MyBatis拦截器 |
| 接口权限 | 注解+拦截器 |
| 认证授权 | OAuth2 + JWT |
最佳实践:
- 前后端都要做权限控制
- 权限缓存及时刷新
- 核心业务做数据权限
- 统一权限模型,不要硬编码
- 定期审计权限配置
血的教训:
权限漏洞不是技术问题,是安全意识问题。每个接口上线前,都要问自己:谁能调?能调什么?能看到什么?
思考题: 你的系统权限控制做到了哪一级?有没有遗漏?
个人观点,仅供参考