feat(auth): 实现多租户权限管理和认证版本控制

- 新增 AuthScopeService 和 AuthVersionService 接口及实现类
- 在 JWT 认证过滤器中集成权限版本验证和缓存机制
- 添加租户管理员角色验证功能和平台管理员权限检查
- 实现角色权限变更时的用户认证版本失效机制
- 完善数据库表结构约束,加强租户数据隔离
- 修复字典项查询权限注解和用户角色关联逻辑
- 优化权限查询和角色管理的安全性检查
master
chenhao 2026-03-03 09:20:13 +08:00
parent f93d797382
commit 7f4d2f54e1
24 changed files with 585 additions and 70 deletions

View File

@ -69,8 +69,8 @@
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| role_id | BIGSERIAL | PK | 角色ID | | role_id | BIGSERIAL | PK | 角色ID |
| tenant_id | BIGINT | | 租户ID | | tenant_id | BIGINT | NOT NULL | 租户ID |
| role_code | VARCHAR(50) | NOT NULL, UNIQUE | 角色编码 | | role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) |
| role_name | VARCHAR(50) | NOT NULL | 角色名称 | | role_name | VARCHAR(50) | NOT NULL | 角色名称 |
| status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 |
| remark | TEXT | | 备注 | | remark | TEXT | | 备注 |
@ -82,11 +82,11 @@
- `idx_sys_role_tenant``(tenant_id)` - `idx_sys_role_tenant``(tenant_id)`
- `uk_role_code``UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE` - `uk_role_code``UNIQUE (tenant_id, role_code) WHERE is_deleted = FALSE`
### 1.3 `sys_user_role`(用户-角色关联表) ### 1.3 `sys_user_role`(用户-角色关联表,租户强约束
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| id | BIGSERIAL | PK | 关联ID | | id | BIGSERIAL | PK | 关联ID |
| tenant_id | BIGINT | | 租户ID | | tenant_id | BIGINT | NOT NULL | 租户ID |
| user_id | BIGINT | NOT NULL | 用户ID | | user_id | BIGINT | NOT NULL | 用户ID |
| role_id | BIGINT | NOT NULL | 角色ID | | role_id | BIGINT | NOT NULL | 角色ID |
| is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 |
@ -94,7 +94,7 @@
| updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 | | updated_at | TIMESTAMP(6) | NOT NULL, DEFAULT now() | 更新时间 |
唯一约束: 唯一约束:
- `UNIQUE (user_id, role_id)` - `UNIQUE (tenant_id, user_id, role_id) WHERE is_deleted = 0`
### 1.4 `sys_tenant_user`(租户成员关联表) ### 1.4 `sys_tenant_user`(租户成员关联表)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |

View File

@ -72,8 +72,8 @@ DROP TABLE IF EXISTS sys_role CASCADE;
CREATE TABLE sys_role ( CREATE TABLE sys_role (
role_id BIGSERIAL PRIMARY KEY, role_id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT, tenant_id BIGINT NOT NULL,
role_code VARCHAR(50) NOT NULL UNIQUE, role_code VARCHAR(50) NOT NULL,
role_name VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL,
status SMALLINT NOT NULL DEFAULT 1, status SMALLINT NOT NULL DEFAULT 1,
remark TEXT, remark TEXT,
@ -85,18 +85,18 @@ CREATE TABLE sys_role (
CREATE INDEX idx_sys_role_tenant ON sys_role (tenant_id); CREATE INDEX idx_sys_role_tenant ON sys_role (tenant_id);
CREATE UNIQUE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = FALSE; CREATE UNIQUE INDEX uk_role_code ON sys_role (tenant_id, role_code) WHERE is_deleted = FALSE;
-- 用户-角色关联表 (无 tenant_id, 随 User/Role 隔离) -- 用户-角色关联表 (按 tenant_id 强约束,避免跨租户角色污染)
DROP TABLE IF EXISTS sys_user_role CASCADE; DROP TABLE IF EXISTS sys_user_role CASCADE;
CREATE TABLE sys_user_role ( CREATE TABLE sys_user_role (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT, tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL, role_id BIGINT NOT NULL,
is_deleted SMALLINT NOT NULL DEFAULT 0, is_deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(6) NOT NULL DEFAULT now(), created_at TIMESTAMP(6) NOT NULL DEFAULT now(),
updated_at TIMESTAMP(6) NOT NULL DEFAULT now(), updated_at TIMESTAMP(6) NOT NULL DEFAULT now(),
UNIQUE (user_id, role_id) UNIQUE (tenant_id, user_id, role_id)
); );
-- ---------------------------- -- ----------------------------

View File

@ -1,8 +1,11 @@
package com.imeeting.auth; package com.imeeting.auth;
import com.imeeting.common.RedisKeys;
import com.imeeting.entity.SysTenant; import com.imeeting.entity.SysTenant;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.security.LoginUser; import com.imeeting.security.LoginUser;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysParamService; import com.imeeting.service.SysParamService;
import com.imeeting.service.SysPermissionService; import com.imeeting.service.SysPermissionService;
import com.imeeting.mapper.SysTenantMapper; import com.imeeting.mapper.SysTenantMapper;
@ -22,6 +25,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Set; import java.util.Set;
@Component @Component
@ -32,19 +36,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
private final SysParamService sysParamService; private final SysParamService sysParamService;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
@Lazy SysPermissionService sysPermissionService, @Lazy SysPermissionService sysPermissionService,
SysTenantMapper sysTenantMapper, SysTenantMapper sysTenantMapper,
SysUserMapper sysUserMapper, SysUserMapper sysUserMapper,
@Lazy SysParamService sysParamService, @Lazy SysParamService sysParamService,
StringRedisTemplate redisTemplate) { StringRedisTemplate redisTemplate,
AuthScopeService authScopeService,
AuthVersionService authVersionService) {
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.sysPermissionService = sysPermissionService; this.sysPermissionService = sysPermissionService;
this.sysTenantMapper = sysTenantMapper; this.sysTenantMapper = sysTenantMapper;
this.sysUserMapper = sysUserMapper; this.sysUserMapper = sysUserMapper;
this.sysParamService = sysParamService; this.sysParamService = sysParamService;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
} }
@Override @Override
@ -65,6 +75,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String username = claims.get("username", String.class); String username = claims.get("username", String.class);
Long userId = claims.get("userId", Long.class); Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class); Long tenantId = claims.get("tenantId", Long.class);
Number tokenAuthVersionNum = claims.get("authVersion", Number.class);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 1. Validate User Status (Ignore Tenant isolation here) // 1. Validate User Status (Ignore Tenant isolation here)
@ -103,20 +114,32 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
} }
} }
long currentAuthVersion = authVersionService.getVersion(userId, activeTenantId);
long requestAuthVersion = tokenAuthVersionNum == null ? 0L : tokenAuthVersionNum.longValue();
if (currentAuthVersion != requestAuthVersion) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":\"401\",\"msg\":\"Token revoked\"}");
return;
}
// 3. Get Permissions (With Redis Cache, Key must include tenantId) // 3. Get Permissions (With Redis Cache, Key must include tenantId)
String permKey = "sys:auth:perm:" + userId + ":" + activeTenantId; String permKey = RedisKeys.authPermKey(userId, activeTenantId, currentAuthVersion);
Set<String> permissions; Set<String> permissions;
String cachedPerms = redisTemplate.opsForValue().get(permKey); String cachedPerms = redisTemplate.opsForValue().get(permKey);
if (cachedPerms != null) { if (cachedPerms != null && !cachedPerms.trim().isEmpty()) {
permissions = Set.of(cachedPerms.split(",")); permissions = Set.of(cachedPerms.split(","));
} else { } else {
permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId); permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId);
if (permissions != null && !permissions.isEmpty()) { if (permissions != null && !permissions.isEmpty()) {
redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2)); redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2));
} else {
permissions = Collections.emptySet();
} }
} }
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), permissions); boolean isTenantAdmin = authScopeService.isTenantAdmin(userId, activeTenantId);
LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), isTenantAdmin, permissions);
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());

View File

@ -15,6 +15,14 @@ public final class RedisKeys {
return "refresh:" + userId + ":" + deviceCode; return "refresh:" + userId + ":" + deviceCode;
} }
public static String authVersionKey(Long userId, Long tenantId) {
return "sys:auth:ver:" + userId + ":" + tenantId;
}
public static String authPermKey(Long userId, Long tenantId, long authVersion) {
return "sys:auth:perm:" + userId + ":" + tenantId + ":" + authVersion;
}
public static String sysParamKey(String paramKey) { public static String sysParamKey(String paramKey) {
return "sys:param:" + paramKey; return "sys:param:" + paramKey;
} }

View File

@ -55,7 +55,7 @@ public class DictItemController {
} }
@GetMapping("/type/{typeCode}") @GetMapping("/type/{typeCode}")
@PreAuthorize("@ss.hasPermi('sys_dict:query')") // @PreAuthorize("@ss.hasPermi('sys_dict:query')")
public ApiResponse<List<SysDictItem>> getByType(@PathVariable String typeCode) { public ApiResponse<List<SysDictItem>> getByType(@PathVariable String typeCode) {
return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode)); return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode));
} }

View File

@ -1,11 +1,14 @@
package com.imeeting.controller; package com.imeeting.controller;
import com.imeeting.auth.JwtTokenProvider;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.dto.PermissionNode; import com.imeeting.dto.PermissionNode;
import com.imeeting.entity.SysPermission; import com.imeeting.entity.SysPermission;
import com.imeeting.entity.SysRole;
import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysPermissionService; import com.imeeting.service.SysPermissionService;
import io.jsonwebtoken.Claims; import com.imeeting.service.SysRoleService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -19,11 +22,19 @@ import java.util.Map;
@RequestMapping("/api/permissions") @RequestMapping("/api/permissions")
public class PermissionController { public class PermissionController {
private final SysPermissionService sysPermissionService; private final SysPermissionService sysPermissionService;
private final JwtTokenProvider jwtTokenProvider; private final SysRolePermissionMapper sysRolePermissionMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysRoleService sysRoleService;
private final AuthVersionService authVersionService;
public PermissionController(SysPermissionService sysPermissionService, JwtTokenProvider jwtTokenProvider) { public PermissionController(SysPermissionService sysPermissionService,
SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper,
SysRoleService sysRoleService, AuthVersionService authVersionService) {
this.sysPermissionService = sysPermissionService; this.sysPermissionService = sysPermissionService;
this.jwtTokenProvider = jwtTokenProvider; this.sysRolePermissionMapper = sysRolePermissionMapper;
this.sysUserRoleMapper = sysUserRoleMapper;
this.sysRoleService = sysRoleService;
this.authVersionService = authVersionService;
} }
@GetMapping @GetMapping
@ -80,6 +91,7 @@ public class PermissionController {
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:permission:update')") @PreAuthorize("@ss.hasPermi('sys:permission:update')")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysPermission perm) { public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysPermission perm) {
List<Long> roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id);
perm.setPermId(id); perm.setPermId(id);
String error = validateParent(perm); String error = validateParent(perm);
if (error != null) { if (error != null) {
@ -92,13 +104,21 @@ public class PermissionController {
.eq(SysPermission::getPermId, id) .eq(SysPermission::getPermId, id)
.update(); .update();
} }
if (updated) {
invalidateRoleUsers(roleIds);
}
return ApiResponse.ok(updated); return ApiResponse.ok(updated);
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:permission:delete')") @PreAuthorize("@ss.hasPermi('sys:permission:delete')")
public ApiResponse<Boolean> delete(@PathVariable Long id) { public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysPermissionService.removeById(id)); List<Long> roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id);
boolean removed = sysPermissionService.removeById(id);
if (removed) {
invalidateRoleUsers(roleIds);
}
return ApiResponse.ok(removed);
} }
private Long getCurrentUserId() { private Long getCurrentUserId() {
@ -191,4 +211,21 @@ public class PermissionController {
node.setMeta(p.getMeta()); node.setMeta(p.getMeta());
return node; return node;
} }
private void invalidateRoleUsers(List<Long> roleIds) {
if (roleIds == null || roleIds.isEmpty()) {
return;
}
for (Long roleId : roleIds) {
if (roleId == null) {
continue;
}
SysRole role = sysRoleService.getById(roleId);
if (role == null || role.getTenantId() == null) {
continue;
}
List<Long> userIds = sysUserRoleMapper.selectUserIdsByRoleId(roleId);
authVersionService.invalidateUsersTenantAuth(userIds, role.getTenantId());
}
}
} }

View File

@ -9,10 +9,14 @@ import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole; import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysRolePermissionMapper; import com.imeeting.mapper.SysRolePermissionMapper;
import com.imeeting.mapper.SysUserRoleMapper; import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysRoleService; import com.imeeting.service.SysRoleService;
import com.imeeting.service.SysUserService; import com.imeeting.service.SysUserService;
import com.imeeting.service.SysPermissionService; import com.imeeting.service.SysPermissionService;
import com.imeeting.service.SysTenantUserService;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList; import java.util.ArrayList;
@ -28,15 +32,24 @@ public class RoleController {
private final SysRolePermissionMapper sysRolePermissionMapper; private final SysRolePermissionMapper sysRolePermissionMapper;
private final SysUserRoleMapper sysUserRoleMapper; private final SysUserRoleMapper sysUserRoleMapper;
private final SysPermissionService sysPermissionService; private final SysPermissionService sysPermissionService;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
private final SysTenantUserService sysTenantUserService;
public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, public RoleController(SysRoleService sysRoleService, SysUserService sysUserService,
SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper, SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper,
SysPermissionService sysPermissionService) { SysPermissionService sysPermissionService,
AuthScopeService authScopeService,
AuthVersionService authVersionService,
SysTenantUserService sysTenantUserService) {
this.sysRoleService = sysRoleService; this.sysRoleService = sysRoleService;
this.sysUserService = sysUserService; this.sysUserService = sysUserService;
this.sysRolePermissionMapper = sysRolePermissionMapper; this.sysRolePermissionMapper = sysRolePermissionMapper;
this.sysUserRoleMapper = sysUserRoleMapper; this.sysUserRoleMapper = sysUserRoleMapper;
this.sysPermissionService = sysPermissionService; this.sysPermissionService = sysPermissionService;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
this.sysTenantUserService = sysTenantUserService;
} }
@GetMapping @GetMapping
@ -48,19 +61,42 @@ public class RoleController {
@GetMapping("/{id}/users") @GetMapping("/{id}/users")
@PreAuthorize("@ss.hasPermi('sys:role:query')") @PreAuthorize("@ss.hasPermi('sys:role:query')")
public ApiResponse<List<SysUser>> listUsers(@PathVariable Long id) { public ApiResponse<List<SysUser>> listUsers(@PathVariable Long id) {
SysRole role = sysRoleService.getById(id);
if (role == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色用户");
}
return ApiResponse.ok(sysUserService.listUsersByRoleId(id)); return ApiResponse.ok(sysUserService.listUsersByRoleId(id));
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:role:query')") @PreAuthorize("@ss.hasPermi('sys:role:query')")
public ApiResponse<SysRole> get(@PathVariable Long id) { public ApiResponse<SysRole> get(@PathVariable Long id) {
return ApiResponse.ok(sysRoleService.getById(id)); SysRole role = sysRoleService.getById(id);
if (role == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色");
}
return ApiResponse.ok(role);
} }
@PostMapping @PostMapping
@PreAuthorize("@ss.hasPermi('sys:role:create')") @PreAuthorize("@ss.hasPermi('sys:role:create')")
@Log(value = "新增角色", type = "角色管理") @Log(value = "新增角色", type = "角色管理")
public ApiResponse<Boolean> create(@RequestBody SysRole role) { public ApiResponse<Boolean> create(@RequestBody SysRole role) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin()) {
role.setTenantId(currentTenantId);
} else if (role.getTenantId() == null) {
return ApiResponse.error("tenantId required for platform role creation");
}
return ApiResponse.ok(sysRoleService.save(role)); return ApiResponse.ok(sysRoleService.save(role));
} }
@ -68,7 +104,15 @@ public class RoleController {
@PreAuthorize("@ss.hasPermi('sys:role:update')") @PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "修改角色", type = "角色管理") @Log(value = "修改角色", type = "角色管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) { public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysRole role) {
SysRole existing = sysRoleService.getById(id);
if (existing == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(existing.getTenantId())) {
return ApiResponse.error("禁止跨租户修改角色");
}
role.setRoleId(id); role.setRoleId(id);
role.setTenantId(existing.getTenantId());
return ApiResponse.ok(sysRoleService.updateById(role)); return ApiResponse.ok(sysRoleService.updateById(role));
} }
@ -76,12 +120,34 @@ public class RoleController {
@PreAuthorize("@ss.hasPermi('sys:role:delete')") @PreAuthorize("@ss.hasPermi('sys:role:delete')")
@Log(value = "删除角色", type = "角色管理") @Log(value = "删除角色", type = "角色管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) { public ApiResponse<Boolean> delete(@PathVariable Long id) {
return ApiResponse.ok(sysRoleService.removeById(id)); SysRole existing = sysRoleService.getById(id);
if (existing == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(existing.getTenantId())) {
return ApiResponse.error("禁止跨租户删除角色");
}
if ("TENANT_ADMIN".equalsIgnoreCase(existing.getRoleCode()) && !authScopeService.isCurrentPlatformAdmin()) {
return ApiResponse.error("租户管理员角色只能由平台管理员删除");
}
List<Long> userIds = sysUserRoleMapper.selectUserIdsByRoleId(id);
boolean removed = sysRoleService.removeById(id);
if (removed) {
authVersionService.invalidateUsersTenantAuth(userIds, existing.getTenantId());
}
return ApiResponse.ok(removed);
} }
@GetMapping("/{id}/permissions") @GetMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermi('sys:role:permission:list')") @PreAuthorize("@ss.hasPermi('sys:role:permission:list')")
public ApiResponse<List<Long>> listRolePermissions(@PathVariable Long id) { public ApiResponse<List<Long>> listRolePermissions(@PathVariable Long id) {
SysRole targetRole = sysRoleService.getById(id);
if (targetRole == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(targetRole.getTenantId())) {
return ApiResponse.error("禁止跨租户查看角色权限");
}
List<SysRolePermission> rows = sysRolePermissionMapper.selectList( List<SysRolePermission> rows = sysRolePermissionMapper.selectList(
new QueryWrapper<SysRolePermission>().eq("role_id", id) new QueryWrapper<SysRolePermission>().eq("role_id", id)
); );
@ -96,11 +162,15 @@ public class RoleController {
@PostMapping("/{id}/permissions") @PostMapping("/{id}/permissions")
@PreAuthorize("@ss.hasPermi('sys:role:permission:save')") @PreAuthorize("@ss.hasPermi('sys:role:permission:save')")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) { public ApiResponse<Boolean> saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) {
List<Long> permIds = payload == null ? null : payload.getPermIds(); List<Long> permIds = payload == null ? null : payload.getPermIds();
// 权限越权校验 // 权限越权校验
Long currentTenantId = getCurrentTenantId(); Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
SysRole targetRole = sysRoleService.getById(id); SysRole targetRole = sysRoleService.getById(id);
if (targetRole == null) { if (targetRole == null) {
return ApiResponse.error("角色不存在"); return ApiResponse.error("角色不存在");
@ -108,12 +178,15 @@ public class RoleController {
// 关键校验:只有平台管理员可以修改 TENANT_ADMIN 角色的权限 // 关键校验:只有平台管理员可以修改 TENANT_ADMIN 角色的权限
if ("TENANT_ADMIN".equalsIgnoreCase(targetRole.getRoleCode())) { if ("TENANT_ADMIN".equalsIgnoreCase(targetRole.getRoleCode())) {
if (!Long.valueOf(0).equals(currentTenantId)) { if (!authScopeService.isCurrentPlatformAdmin()) {
return ApiResponse.error("租户管理员角色的权限只能由平台管理员修改"); return ApiResponse.error("租户管理员角色的权限只能由平台管理员修改");
} }
} }
if (!canAccessTenant(targetRole.getTenantId())) {
return ApiResponse.error("禁止跨租户修改角色权限");
}
if (!Long.valueOf(0).equals(currentTenantId)) { if (!authScopeService.isCurrentPlatformAdmin()) {
List<com.imeeting.entity.SysPermission> myPerms = sysPermissionService.listByUserId(getCurrentUserId(), currentTenantId); List<com.imeeting.entity.SysPermission> myPerms = sysPermissionService.listByUserId(getCurrentUserId(), currentTenantId);
Set<Long> myPermIds = myPerms.stream() Set<Long> myPermIds = myPerms.stream()
@ -131,6 +204,7 @@ public class RoleController {
sysRolePermissionMapper.delete(new QueryWrapper<SysRolePermission>().eq("role_id", id)); sysRolePermissionMapper.delete(new QueryWrapper<SysRolePermission>().eq("role_id", id));
if (permIds == null || permIds.isEmpty()) { if (permIds == null || permIds.isEmpty()) {
authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
for (Long permId : permIds) { for (Long permId : permIds) {
@ -142,25 +216,53 @@ public class RoleController {
item.setPermId(permId); item.setPermId(permId);
sysRolePermissionMapper.insert(item); sysRolePermissionMapper.insert(item);
} }
authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@PostMapping("/{id}/users") @PostMapping("/{id}/users")
@PreAuthorize("@ss.hasPermi('sys:role:update')") @PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "角色关联用户", type = "角色管理") @Log(value = "角色关联用户", type = "角色管理")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) { public ApiResponse<Boolean> bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) {
if (payload == null || payload.getUserIds() == null) { if (payload == null || payload.getUserIds() == null) {
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
SysRole role = sysRoleService.getById(id);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户绑定用户");
}
List<Long> toInsertUserIds = new ArrayList<>();
for (Long userId : payload.getUserIds()) { for (Long userId : payload.getUserIds()) {
if (userId == null) {
continue;
}
QueryWrapper<SysUserRole> qw = new QueryWrapper<>(); QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
qw.eq("role_id", id).eq("user_id", userId); qw.eq("role_id", id).eq("user_id", userId).eq("tenant_id", role.getTenantId());
if (sysUserRoleMapper.selectCount(qw) == 0) { if (sysUserRoleMapper.selectCount(qw) == 0) {
boolean hasMembership = sysTenantUserService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
.eq(com.imeeting.entity.SysTenantUser::getUserId, userId)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, role.getTenantId())
) > 0;
if (!hasMembership) {
return ApiResponse.error("用户不属于角色所在租户:" + role.getTenantId());
}
toInsertUserIds.add(userId);
}
}
for (Long userId : toInsertUserIds) {
SysUserRole ur = new SysUserRole(); SysUserRole ur = new SysUserRole();
ur.setTenantId(role.getTenantId());
ur.setRoleId(id); ur.setRoleId(id);
ur.setUserId(userId); ur.setUserId(userId);
sysUserRoleMapper.insert(ur); sysUserRoleMapper.insert(ur);
} authVersionService.invalidateUserTenantAuth(userId, role.getTenantId());
} }
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@ -168,10 +270,19 @@ public class RoleController {
@DeleteMapping("/{id}/users/{userId}") @DeleteMapping("/{id}/users/{userId}")
@PreAuthorize("@ss.hasPermi('sys:role:update')") @PreAuthorize("@ss.hasPermi('sys:role:update')")
@Log(value = "角色取消关联用户", type = "角色管理") @Log(value = "角色取消关联用户", type = "角色管理")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> unbindUser(@PathVariable Long id, @PathVariable Long userId) { public ApiResponse<Boolean> unbindUser(@PathVariable Long id, @PathVariable Long userId) {
SysRole role = sysRoleService.getById(id);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在");
}
if (!canAccessTenant(role.getTenantId())) {
return ApiResponse.error("禁止跨租户解绑用户");
}
QueryWrapper<SysUserRole> qw = new QueryWrapper<>(); QueryWrapper<SysUserRole> qw = new QueryWrapper<>();
qw.eq("role_id", id).eq("user_id", userId); qw.eq("role_id", id).eq("user_id", userId).eq("tenant_id", role.getTenantId());
sysUserRoleMapper.delete(qw); sysUserRoleMapper.delete(qw);
authVersionService.invalidateUserTenantAuth(userId, role.getTenantId());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@ -191,6 +302,17 @@ public class RoleController {
return null; return null;
} }
private boolean canAccessTenant(Long targetTenantId) {
if (targetTenantId == null) {
return false;
}
if (authScopeService.isCurrentPlatformAdmin()) {
return true;
}
Long currentTenantId = getCurrentTenantId();
return currentTenantId != null && currentTenantId.equals(targetTenantId);
}
public static class UserBindingPayload { public static class UserBindingPayload {
private List<Long> userIds; private List<Long> userIds;
public List<Long> getUserIds() { return userIds; } public List<Long> getUserIds() { return userIds; }

View File

@ -1,6 +1,5 @@
package com.imeeting.controller; package com.imeeting.controller;
import com.imeeting.auth.JwtTokenProvider;
import com.imeeting.common.ApiResponse; import com.imeeting.common.ApiResponse;
import com.imeeting.dto.PasswordUpdateDTO; import com.imeeting.dto.PasswordUpdateDTO;
import com.imeeting.dto.UserProfile; import com.imeeting.dto.UserProfile;
@ -10,13 +9,15 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUser;
import com.imeeting.entity.SysUserRole; import com.imeeting.entity.SysUserRole;
import com.imeeting.mapper.SysUserRoleMapper; import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.service.AuthScopeService;
import com.imeeting.service.AuthVersionService;
import com.imeeting.service.SysUserService; import com.imeeting.service.SysUserService;
import com.imeeting.common.annotation.Log; import com.imeeting.common.annotation.Log;
import io.jsonwebtoken.Claims;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList; import java.util.ArrayList;
@ -27,21 +28,25 @@ import java.util.List;
public class UserController { public class UserController {
private final SysUserService sysUserService; private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final SysUserRoleMapper sysUserRoleMapper; private final SysUserRoleMapper sysUserRoleMapper;
private final com.imeeting.service.SysTenantUserService sysTenantUserService; private final com.imeeting.service.SysTenantUserService sysTenantUserService;
private final com.imeeting.service.SysRoleService sysRoleService; private final com.imeeting.service.SysRoleService sysRoleService;
private final AuthScopeService authScopeService;
private final AuthVersionService authVersionService;
public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper, SysUserRoleMapper sysUserRoleMapper,
com.imeeting.service.SysTenantUserService sysTenantUserService, com.imeeting.service.SysTenantUserService sysTenantUserService,
com.imeeting.service.SysRoleService sysRoleService) { com.imeeting.service.SysRoleService sysRoleService,
AuthScopeService authScopeService,
AuthVersionService authVersionService) {
this.sysUserService = sysUserService; this.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider;
this.sysUserRoleMapper = sysUserRoleMapper; this.sysUserRoleMapper = sysUserRoleMapper;
this.sysTenantUserService = sysTenantUserService; this.sysTenantUserService = sysTenantUserService;
this.sysRoleService = sysRoleService; this.sysRoleService = sysRoleService;
this.authScopeService = authScopeService;
this.authVersionService = authVersionService;
} }
@GetMapping @GetMapping
@ -49,11 +54,12 @@ public class UserController {
public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) { public ApiResponse<List<SysUser>> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) {
Long currentTenantId = getCurrentTenantId(); Long currentTenantId = getCurrentTenantId();
List<SysUser> users; List<SysUser> users;
Long targetTenantId = null;
if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) { if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) {
users = sysUserService.list(); users = sysUserService.list();
} else { } else {
Long targetTenantId = tenantId != null ? tenantId : currentTenantId; targetTenantId = tenantId != null ? tenantId : currentTenantId;
if (targetTenantId == null) { if (targetTenantId == null) {
return ApiResponse.error("Tenant ID required"); return ApiResponse.error("Tenant ID required");
} }
@ -66,9 +72,11 @@ public class UserController {
user.setMemberships(sysTenantUserService.listByUserId(user.getUserId())); user.setMemberships(sysTenantUserService.listByUserId(user.getUserId()));
// 加载角色信息 // 加载角色信息
List<SysUserRole> userRoles = sysUserRoleMapper.selectList( QueryWrapper<SysUserRole> roleQuery = new QueryWrapper<SysUserRole>().eq("user_id", user.getUserId());
new QueryWrapper<SysUserRole>().eq("user_id", user.getUserId()) if (targetTenantId != null) {
); roleQuery.eq("tenant_id", targetTenantId);
}
List<SysUserRole> userRoles = sysUserRoleMapper.selectList(roleQuery);
if (userRoles != null && !userRoles.isEmpty()) { if (userRoles != null && !userRoles.isEmpty()) {
List<Long> roleIds = userRoles.stream() List<Long> roleIds = userRoles.stream()
.map(SysUserRole::getRoleId) .map(SysUserRole::getRoleId)
@ -102,6 +110,7 @@ public class UserController {
profile.setStatus(user.getStatus()); profile.setStatus(user.getStatus());
profile.setAdmin(userId == 1L); profile.setAdmin(userId == 1L);
profile.setIsPlatformAdmin(user.getIsPlatformAdmin()); profile.setIsPlatformAdmin(user.getIsPlatformAdmin());
profile.setIsTenantAdmin(loginUser.getIsTenantAdmin());
profile.setPwdResetRequired(user.getPwdResetRequired()); profile.setPwdResetRequired(user.getPwdResetRequired());
return ApiResponse.ok(profile); return ApiResponse.ok(profile);
} }
@ -109,6 +118,13 @@ public class UserController {
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("@ss.hasPermi('sys:user:query')") @PreAuthorize("@ss.hasPermi('sys:user:query')")
public ApiResponse<SysUser> get(@PathVariable Long id) { public ApiResponse<SysUser> get(@PathVariable Long id) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户查看用户");
}
SysUser user = sysUserService.getByIdIgnoreTenant(id); SysUser user = sysUserService.getByIdIgnoreTenant(id);
if (user != null) { if (user != null) {
user.setMemberships(sysTenantUserService.listByUserId(id)); user.setMemberships(sysTenantUserService.listByUserId(id));
@ -129,6 +145,9 @@ public class UserController {
@Log(value = "新增用户", type = "用户管理") @Log(value = "新增用户", type = "用户管理")
public ApiResponse<Boolean> create(@RequestBody SysUser user) { public ApiResponse<Boolean> create(@RequestBody SysUser user) {
Long currentTenantId = getCurrentTenantId(); Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
// 非平台管理员强制设置为当前租户 // 非平台管理员强制设置为当前租户
if (!Long.valueOf(0).equals(currentTenantId)) { if (!Long.valueOf(0).equals(currentTenantId)) {
if (user.getMemberships() != null && !user.getMemberships().isEmpty()) { if (user.getMemberships() != null && !user.getMemberships().isEmpty()) {
@ -158,7 +177,13 @@ public class UserController {
@Log(value = "修改用户", type = "用户管理") @Log(value = "修改用户", type = "用户管理")
public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) { public ApiResponse<Boolean> update(@PathVariable Long id, @RequestBody SysUser user) {
Long currentTenantId = getCurrentTenantId(); Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
user.setUserId(id); user.setUserId(id);
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户修改用户");
}
// 非平台管理员强制约束租户身份 // 非平台管理员强制约束租户身份
if (!Long.valueOf(0).equals(currentTenantId)) { if (!Long.valueOf(0).equals(currentTenantId)) {
@ -181,6 +206,13 @@ public class UserController {
@PreAuthorize("@ss.hasPermi('sys:user:delete')") @PreAuthorize("@ss.hasPermi('sys:user:delete')")
@Log(value = "删除用户", type = "用户管理") @Log(value = "删除用户", type = "用户管理")
public ApiResponse<Boolean> delete(@PathVariable Long id) { public ApiResponse<Boolean> delete(@PathVariable Long id) {
Long currentTenantId = getCurrentTenantId();
if (currentTenantId == null) {
return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户删除用户");
}
return ApiResponse.ok(sysUserService.removeById(id)); return ApiResponse.ok(sysUserService.removeById(id));
} }
@ -222,9 +254,18 @@ public class UserController {
@GetMapping("/{id}/roles") @GetMapping("/{id}/roles")
@PreAuthorize("@ss.hasPermi('sys:user:role:list')") @PreAuthorize("@ss.hasPermi('sys:user:role:list')")
public ApiResponse<List<Long>> listUserRoles(@PathVariable Long id) { public ApiResponse<List<Long>> listUserRoles(@PathVariable Long id) {
List<SysUserRole> rows = sysUserRoleMapper.selectList( Long currentTenantId = getCurrentTenantId();
new QueryWrapper<SysUserRole>().eq("user_id", id) if (currentTenantId == null) {
); return ApiResponse.error("Tenant ID required");
}
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户查看用户角色");
}
QueryWrapper<SysUserRole> query = new QueryWrapper<SysUserRole>().eq("user_id", id);
if (!authScopeService.isCurrentPlatformAdmin()) {
query.eq("tenant_id", currentTenantId);
}
List<SysUserRole> rows = sysUserRoleMapper.selectList(query);
List<Long> roleIds = new ArrayList<>(); List<Long> roleIds = new ArrayList<>();
for (SysUserRole row : rows) { for (SysUserRole row : rows) {
if (row.getRoleId() != null) { if (row.getRoleId() != null) {
@ -236,31 +277,84 @@ public class UserController {
@PostMapping("/{id}/roles") @PostMapping("/{id}/roles")
@PreAuthorize("@ss.hasPermi('sys:user:role:save')") @PreAuthorize("@ss.hasPermi('sys:user:role:save')")
@Transactional(rollbackFor = Exception.class)
public ApiResponse<Boolean> saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) { public ApiResponse<Boolean> saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) {
List<Long> roleIds = payload == null ? null : payload.getRoleIds(); Long currentTenantId = getCurrentTenantId();
sysUserRoleMapper.delete(new QueryWrapper<SysUserRole>().eq("user_id", id)); if (currentTenantId == null) {
if (roleIds == null || roleIds.isEmpty()) { return ApiResponse.error("Tenant ID required");
return ApiResponse.ok(true);
} }
if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) {
return ApiResponse.error("禁止跨租户分配角色");
}
List<Long> roleIds = payload == null ? null : payload.getRoleIds();
List<com.imeeting.entity.SysRole> rolesToBind = new ArrayList<>();
if (roleIds != null) {
for (Long roleId : roleIds) { for (Long roleId : roleIds) {
if (roleId == null) { if (roleId == null) {
continue; continue;
} }
com.imeeting.entity.SysRole role = sysRoleService.getById(roleId);
if (role == null || role.getRoleId() == null || role.getTenantId() == null) {
return ApiResponse.error("角色不存在:" + roleId);
}
Long roleTenantId = role.getTenantId();
if (!authScopeService.isCurrentPlatformAdmin() && !currentTenantId.equals(roleTenantId)) {
return ApiResponse.error("禁止跨租户分配角色:" + roleId);
}
boolean hasMembership = sysTenantUserService.count(
new LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
.eq(com.imeeting.entity.SysTenantUser::getUserId, id)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, roleTenantId)
) > 0;
if (!hasMembership) {
return ApiResponse.error("用户不属于角色所在租户:" + roleTenantId);
}
rolesToBind.add(role);
}
}
QueryWrapper<SysUserRole> scopeQuery = new QueryWrapper<SysUserRole>().eq("user_id", id);
if (!authScopeService.isCurrentPlatformAdmin()) {
scopeQuery.eq("tenant_id", currentTenantId);
}
List<SysUserRole> existingRows = sysUserRoleMapper.selectList(scopeQuery);
java.util.Set<Long> affectedTenantIds = new java.util.HashSet<>();
for (SysUserRole row : existingRows) {
if (row.getTenantId() != null) {
affectedTenantIds.add(row.getTenantId());
}
}
for (com.imeeting.entity.SysRole role : rolesToBind) {
if (role.getTenantId() != null) {
affectedTenantIds.add(role.getTenantId());
}
}
sysUserRoleMapper.delete(scopeQuery);
for (com.imeeting.entity.SysRole role : rolesToBind) {
SysUserRole item = new SysUserRole(); SysUserRole item = new SysUserRole();
item.setTenantId(role.getTenantId());
item.setUserId(id); item.setUserId(id);
item.setRoleId(roleId); item.setRoleId(role.getRoleId());
sysUserRoleMapper.insert(item); sysUserRoleMapper.insert(item);
} }
for (Long tenantId : affectedTenantIds) {
authVersionService.invalidateUserTenantAuth(id, tenantId);
}
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
private Long resolveUserId(String authorization) { private boolean isUserInTenant(Long userId, Long tenantId) {
if (authorization == null || !authorization.startsWith("Bearer ")) { if (userId == null || tenantId == null) {
return null; return false;
} }
String token = authorization.substring(7); return sysTenantUserService.count(
Claims claims = jwtTokenProvider.parseToken(token); new LambdaQueryWrapper<com.imeeting.entity.SysTenantUser>()
return claims.get("userId", Long.class); .eq(com.imeeting.entity.SysTenantUser::getUserId, userId)
.eq(com.imeeting.entity.SysTenantUser::getTenantId, tenantId)
) > 0;
} }
public static class RoleBindingPayload { public static class RoleBindingPayload {

View File

@ -0,0 +1,14 @@
package com.imeeting.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class CreateTenantDTO {
private String tenantCode;
private String tenantName;
private String contactName;
private String contactPhone;
private String remark;
private LocalDateTime expireTime;
}

View File

@ -0,0 +1,9 @@
package com.imeeting.dto;
import lombok.Data;
@Data
public class PasswordUpdateDTO {
private String oldPassword;
private String newPassword;
}

View File

@ -14,5 +14,6 @@ public class UserProfile {
@JsonProperty("isAdmin") @JsonProperty("isAdmin")
private boolean isAdmin; private boolean isAdmin;
private Boolean isPlatformAdmin; private Boolean isPlatformAdmin;
private Boolean isTenantAdmin;
private Integer pwdResetRequired; private Integer pwdResetRequired;
} }

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
@ -14,8 +15,11 @@ import java.time.LocalDateTime;
public class SysUserRole { public class SysUserRole {
@TableId(value = "id", type = IdType.AUTO) @TableId(value = "id", type = IdType.AUTO)
private Long id; private Long id;
private Long tenantId;
private Long userId; private Long userId;
private Long roleId; private Long roleId;
@TableLogic(value = "0", delval = "1")
private Integer isDeleted;
@TableField(fill = FieldFill.INSERT) @TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@ -14,10 +14,15 @@ public interface SysPermissionMapper extends BaseMapper<SysPermission> {
@Select(""" @Select("""
SELECT DISTINCT p.* SELECT DISTINCT p.*
FROM sys_permission p FROM sys_permission p
JOIN sys_role_permission rp ON rp.perm_id = p.perm_id and p.is_deleted=0 JOIN sys_role_permission rp ON rp.perm_id = p.perm_id
JOIN sys_role r ON r.role_id = rp.role_id JOIN sys_role r ON r.role_id = rp.role_id
JOIN sys_user_role ur ON ur.role_id = r.role_id JOIN sys_user_role ur ON ur.role_id = r.role_id
WHERE ur.user_id = #{userId} AND r.tenant_id = #{tenantId} WHERE p.is_deleted = 0
AND r.is_deleted = 0
AND ur.is_deleted = 0
AND ur.user_id = #{userId}
AND r.tenant_id = #{tenantId}
AND (ur.tenant_id = #{tenantId} OR ur.tenant_id IS NULL)
""") """)
List<SysPermission> selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId); List<SysPermission> selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
} }

View File

@ -2,7 +2,18 @@ package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysRolePermission; import com.imeeting.entity.SysRolePermission;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper @Mapper
public interface SysRolePermissionMapper extends BaseMapper<SysRolePermission> {} public interface SysRolePermissionMapper extends BaseMapper<SysRolePermission> {
@Select("""
SELECT DISTINCT role_id
FROM sys_role_permission
WHERE perm_id = #{permId}
""")
List<Long> selectRoleIdsByPermId(@Param("permId") Long permId);
}

View File

@ -10,7 +10,14 @@ import java.util.List;
@Mapper @Mapper
public interface SysUserMapper extends BaseMapper<SysUser> { public interface SysUserMapper extends BaseMapper<SysUser> {
@Select("SELECT u.* FROM sys_user u JOIN sys_user_role ur ON u.user_id = ur.user_id WHERE ur.role_id = #{roleId}") @Select("""
SELECT u.*
FROM sys_user u
JOIN sys_user_role ur ON u.user_id = ur.user_id
WHERE ur.role_id = #{roleId}
AND ur.is_deleted = 0
AND u.is_deleted = 0
""")
List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId); List<SysUser> selectUsersByRoleId(@Param("roleId") Long roleId);
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")

View File

@ -2,7 +2,41 @@ package com.imeeting.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.imeeting.entity.SysUserRole; import com.imeeting.entity.SysUserRole;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper @Mapper
public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {} public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
@Select("""
SELECT COUNT(1)
FROM sys_user_role ur
JOIN sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = #{userId}
AND (ur.tenant_id = #{tenantId} OR ur.tenant_id IS NULL)
AND ur.is_deleted = 0
AND r.is_deleted = 0
AND r.tenant_id = #{tenantId}
AND r.role_code = 'TENANT_ADMIN'
""")
Long countTenantAdminRole(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
@Select("""
SELECT DISTINCT ur.user_id
FROM sys_user_role ur
WHERE ur.role_id = #{roleId}
AND ur.is_deleted = 0
""")
List<Long> selectUserIdsByRoleId(@Param("roleId") Long roleId);
@Select("""
SELECT ur.role_id
FROM sys_user_role ur
WHERE ur.user_id = #{userId}
AND ur.tenant_id = #{tenantId}
AND ur.is_deleted = 0
""")
List<Long> selectRoleIdsByUserIdAndTenantId(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
}

View File

@ -19,6 +19,7 @@ public class LoginUser implements UserDetails {
private Long tenantId; private Long tenantId;
private String username; private String username;
private Boolean isPlatformAdmin; private Boolean isPlatformAdmin;
private Boolean isTenantAdmin;
private Set<String> permissions; private Set<String> permissions;
@Override @Override

View File

@ -24,11 +24,10 @@ public class PermissionService {
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) { if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) {
return false; return false;
} }
LoginUser loginUser = (LoginUser) authentication.getPrincipal(); LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 平台管理员在系统租户(0)下放行全部权限点
// 超级管理员(ID=1) 拥有所有权限 if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
if (loginUser.getUserId() != null && loginUser.getUserId() == 1L) { && Long.valueOf(0L).equals(loginUser.getTenantId())) {
return true; return true;
} }

View File

@ -0,0 +1,9 @@
package com.imeeting.service;
public interface AuthScopeService {
boolean isCurrentPlatformAdmin();
boolean isCurrentTenantAdmin();
boolean isTenantAdmin(Long userId, Long tenantId);
}

View File

@ -0,0 +1,11 @@
package com.imeeting.service;
import java.util.Collection;
public interface AuthVersionService {
long getVersion(Long userId, Long tenantId);
void invalidateUserTenantAuth(Long userId, Long tenantId);
void invalidateUsersTenantAuth(Collection<Long> userIds, Long tenantId);
}

View File

@ -0,0 +1,53 @@
package com.imeeting.service.impl;
import com.imeeting.mapper.SysUserRoleMapper;
import com.imeeting.security.LoginUser;
import com.imeeting.service.AuthScopeService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class AuthScopeServiceImpl implements AuthScopeService {
private final SysUserRoleMapper sysUserRoleMapper;
public AuthScopeServiceImpl(SysUserRoleMapper sysUserRoleMapper) {
this.sysUserRoleMapper = sysUserRoleMapper;
}
@Override
public boolean isCurrentPlatformAdmin() {
LoginUser loginUser = getCurrentLoginUser();
if (loginUser == null) {
return false;
}
return Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())
&& Long.valueOf(0L).equals(loginUser.getTenantId());
}
@Override
public boolean isCurrentTenantAdmin() {
LoginUser loginUser = getCurrentLoginUser();
if (loginUser == null) {
return false;
}
return isTenantAdmin(loginUser.getUserId(), loginUser.getTenantId());
}
@Override
public boolean isTenantAdmin(Long userId, Long tenantId) {
if (userId == null || tenantId == null || tenantId <= 0) {
return false;
}
Long count = sysUserRoleMapper.countTenantAdminRole(userId, tenantId);
return count != null && count > 0;
}
private LoginUser getCurrentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) {
return null;
}
return (LoginUser) authentication.getPrincipal();
}
}

View File

@ -33,6 +33,7 @@ public class AuthServiceImpl implements AuthService {
private final StringRedisTemplate stringRedisTemplate; private final StringRedisTemplate stringRedisTemplate;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final AuthVersionService authVersionService;
private final SysLogService sysLogService; private final SysLogService sysLogService;
private final HttpServletRequest httpServletRequest; private final HttpServletRequest httpServletRequest;
@ -50,6 +51,7 @@ public class AuthServiceImpl implements AuthService {
StringRedisTemplate stringRedisTemplate, StringRedisTemplate stringRedisTemplate,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider, JwtTokenProvider jwtTokenProvider,
AuthVersionService authVersionService,
SysLogService sysLogService, SysLogService sysLogService,
HttpServletRequest httpServletRequest) { HttpServletRequest httpServletRequest) {
this.sysUserService = sysUserService; this.sysUserService = sysUserService;
@ -59,6 +61,7 @@ public class AuthServiceImpl implements AuthService {
this.stringRedisTemplate = stringRedisTemplate; this.stringRedisTemplate = stringRedisTemplate;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtTokenProvider = jwtTokenProvider; this.jwtTokenProvider = jwtTokenProvider;
this.authVersionService = authVersionService;
this.sysLogService = sysLogService; this.sysLogService = sysLogService;
this.httpServletRequest = httpServletRequest; this.httpServletRequest = httpServletRequest;
} }
@ -175,6 +178,11 @@ public class AuthServiceImpl implements AuthService {
Long userId = claims.get("userId", Long.class); Long userId = claims.get("userId", Long.class);
Long tenantId = claims.get("tenantId", Long.class); Long tenantId = claims.get("tenantId", Long.class);
String deviceCode = claims.get("deviceCode", String.class); String deviceCode = claims.get("deviceCode", String.class);
Number tokenAuthVersionNum = claims.get("authVersion", Number.class);
long currentAuthVersion = authVersionService.getVersion(userId, tenantId);
if (currentAuthVersion != (tokenAuthVersionNum == null ? 0L : tokenAuthVersionNum.longValue())) {
throw new IllegalArgumentException("刷新令牌已失效");
}
String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode)); String cached = stringRedisTemplate.opsForValue().get(RedisKeys.refreshTokenKey(userId, deviceCode));
if (cached == null || !cached.equals(refreshToken)) { if (cached == null || !cached.equals(refreshToken)) {
throw new IllegalArgumentException("刷新令牌已失效"); throw new IllegalArgumentException("刷新令牌已失效");
@ -298,18 +306,21 @@ public class AuthServiceImpl implements AuthService {
} }
private TokenResponse issueTokens(SysUser user, Long tenantId, String deviceCode, long accessMinutes, long refreshDays) { private TokenResponse issueTokens(SysUser user, Long tenantId, String deviceCode, long accessMinutes, long refreshDays) {
long authVersion = authVersionService.getVersion(user.getUserId(), tenantId);
Map<String, Object> accessClaims = new HashMap<>(); Map<String, Object> accessClaims = new HashMap<>();
accessClaims.put("tokenType", "access"); accessClaims.put("tokenType", "access");
accessClaims.put("userId", user.getUserId()); accessClaims.put("userId", user.getUserId());
accessClaims.put("tenantId", tenantId); accessClaims.put("tenantId", tenantId);
accessClaims.put("username", user.getUsername()); accessClaims.put("username", user.getUsername());
accessClaims.put("deviceCode", deviceCode); accessClaims.put("deviceCode", deviceCode);
accessClaims.put("authVersion", authVersion);
Map<String, Object> refreshClaims = new HashMap<>(); Map<String, Object> refreshClaims = new HashMap<>();
refreshClaims.put("tokenType", "refresh"); refreshClaims.put("tokenType", "refresh");
refreshClaims.put("userId", user.getUserId()); refreshClaims.put("userId", user.getUserId());
refreshClaims.put("tenantId", tenantId); refreshClaims.put("tenantId", tenantId);
refreshClaims.put("deviceCode", deviceCode); refreshClaims.put("deviceCode", deviceCode);
refreshClaims.put("authVersion", authVersion);
String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis()); String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis());
String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis()); String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis());

View File

@ -0,0 +1,61 @@
package com.imeeting.service.impl;
import com.imeeting.common.RedisKeys;
import com.imeeting.service.AuthVersionService;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Collection;
@Service
public class AuthVersionServiceImpl implements AuthVersionService {
private static final Duration VERSION_TTL = Duration.ofDays(30);
private final StringRedisTemplate stringRedisTemplate;
public AuthVersionServiceImpl(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public long getVersion(Long userId, Long tenantId) {
if (userId == null || tenantId == null) {
return 0L;
}
String value = stringRedisTemplate.opsForValue().get(RedisKeys.authVersionKey(userId, tenantId));
if (value == null || value.trim().isEmpty()) {
return 0L;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return 0L;
}
}
@Override
public void invalidateUserTenantAuth(Long userId, Long tenantId) {
if (userId == null || tenantId == null) {
return;
}
String versionKey = RedisKeys.authVersionKey(userId, tenantId);
Long newVersion = stringRedisTemplate.opsForValue().increment(versionKey);
if (newVersion == null) {
return;
}
stringRedisTemplate.expire(versionKey, VERSION_TTL);
long previousVersion = Math.max(newVersion - 1, 0);
stringRedisTemplate.delete(RedisKeys.authPermKey(userId, tenantId, previousVersion));
stringRedisTemplate.delete(RedisKeys.authPermKey(userId, tenantId, newVersion));
}
@Override
public void invalidateUsersTenantAuth(Collection<Long> userIds, Long tenantId) {
if (userIds == null || userIds.isEmpty() || tenantId == null) {
return;
}
for (Long userId : userIds) {
invalidateUserTenantAuth(userId, tenantId);
}
}
}

View File

@ -175,6 +175,7 @@ public class SysTenantServiceImpl extends ServiceImpl<SysTenantMapper, SysTenant
// 7. 绑定用户与角色 (sys_user_role) // 7. 绑定用户与角色 (sys_user_role)
SysUserRole ur = new SysUserRole(); SysUserRole ur = new SysUserRole();
ur.setTenantId(tenantId);
ur.setUserId(userId); ur.setUserId(userId);
ur.setRoleId(roleId); ur.setRoleId(roleId);
sysUserRoleMapper.insert(ur); sysUserRoleMapper.insert(ur);