diff --git a/backend/design/db_schema.md b/backend/design/db_schema.md index 1ebb54a..393bb14 100644 --- a/backend/design/db_schema.md +++ b/backend/design/db_schema.md @@ -69,8 +69,8 @@ | 字段 | 类型 | 约束 | 说明 | | --- | --- | --- | --- | | role_id | BIGSERIAL | PK | 角色ID | -| tenant_id | BIGINT | | 租户ID | -| role_code | VARCHAR(50) | NOT NULL, UNIQUE | 角色编码 | +| tenant_id | BIGINT | NOT NULL | 租户ID | +| role_code | VARCHAR(50) | NOT NULL | 角色编码(租户内唯一) | | role_name | VARCHAR(50) | NOT NULL | 角色名称 | | status | SMALLINT | NOT NULL, DEFAULT 1 | 状态 | | remark | TEXT | | 备注 | @@ -82,11 +82,11 @@ - `idx_sys_role_tenant`:`(tenant_id)` - `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 | -| tenant_id | BIGINT | | 租户ID | +| tenant_id | BIGINT | NOT NULL | 租户ID | | user_id | BIGINT | NOT NULL | 用户ID | | role_id | BIGINT | NOT NULL | 角色ID | | is_deleted | SMALLINT | NOT NULL, DEFAULT 0 | 逻辑删除标记 | @@ -94,7 +94,7 @@ | 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`(租户成员关联表) | 字段 | 类型 | 约束 | 说明 | diff --git a/backend/design/db_schema_pgsql.sql b/backend/design/db_schema_pgsql.sql index d5fb438..de75f55 100644 --- a/backend/design/db_schema_pgsql.sql +++ b/backend/design/db_schema_pgsql.sql @@ -72,8 +72,8 @@ DROP TABLE IF EXISTS sys_role CASCADE; CREATE TABLE sys_role ( role_id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT, - role_code VARCHAR(50) NOT NULL UNIQUE, + tenant_id BIGINT NOT NULL, + role_code VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL, status SMALLINT NOT NULL DEFAULT 1, remark TEXT, @@ -85,18 +85,18 @@ CREATE TABLE sys_role ( 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; --- 用户-角色关联表 (无 tenant_id, 随 User/Role 隔离) +-- 用户-角色关联表 (按 tenant_id 强约束,避免跨租户角色污染) DROP TABLE IF EXISTS sys_user_role CASCADE; CREATE TABLE sys_user_role ( id BIGSERIAL PRIMARY KEY, - tenant_id BIGINT, + tenant_id BIGINT NOT NULL, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, is_deleted SMALLINT NOT NULL DEFAULT 0, created_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) ); -- ---------------------------- diff --git a/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java index cc8d984..8f4ba51 100644 --- a/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/imeeting/auth/JwtAuthenticationFilter.java @@ -1,8 +1,11 @@ package com.imeeting.auth; +import com.imeeting.common.RedisKeys; import com.imeeting.entity.SysTenant; import com.imeeting.entity.SysUser; import com.imeeting.security.LoginUser; +import com.imeeting.service.AuthScopeService; +import com.imeeting.service.AuthVersionService; import com.imeeting.service.SysParamService; import com.imeeting.service.SysPermissionService; import com.imeeting.mapper.SysTenantMapper; @@ -22,6 +25,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.time.LocalDateTime; +import java.util.Collections; import java.util.Set; @Component @@ -32,19 +36,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final SysUserMapper sysUserMapper; private final SysParamService sysParamService; private final StringRedisTemplate redisTemplate; + private final AuthScopeService authScopeService; + private final AuthVersionService authVersionService; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, @Lazy SysPermissionService sysPermissionService, SysTenantMapper sysTenantMapper, SysUserMapper sysUserMapper, @Lazy SysParamService sysParamService, - StringRedisTemplate redisTemplate) { + StringRedisTemplate redisTemplate, + AuthScopeService authScopeService, + AuthVersionService authVersionService) { this.jwtTokenProvider = jwtTokenProvider; this.sysPermissionService = sysPermissionService; this.sysTenantMapper = sysTenantMapper; this.sysUserMapper = sysUserMapper; this.sysParamService = sysParamService; this.redisTemplate = redisTemplate; + this.authScopeService = authScopeService; + this.authVersionService = authVersionService; } @Override @@ -66,7 +76,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String displayName = claims.get("displayName", String.class); Long userId = claims.get("userId", Long.class); Long tenantId = claims.get("tenantId", Long.class); - + Number tokenAuthVersionNum = claims.get("authVersion", Number.class); + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 1. Validate User Status (Ignore Tenant isolation here) SysUser user = sysUserMapper.selectByIdIgnoreTenant(userId); @@ -104,20 +115,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) - String permKey = "sys:auth:perm:" + userId + ":" + activeTenantId; + String permKey = RedisKeys.authPermKey(userId, activeTenantId, currentAuthVersion); Set permissions; String cachedPerms = redisTemplate.opsForValue().get(permKey); - if (cachedPerms != null) { + if (cachedPerms != null && !cachedPerms.trim().isEmpty()) { permissions = Set.of(cachedPerms.split(",")); } else { permissions = sysPermissionService.listPermissionCodesByUserId(userId, activeTenantId); if (permissions != null && !permissions.isEmpty()) { redisTemplate.opsForValue().set(permKey, String.join(",", permissions), java.time.Duration.ofHours(2)); + } else { + permissions = Collections.emptySet(); } } - LoginUser loginUser = new LoginUser(userId, activeTenantId, username, displayName, user.getIsPlatformAdmin(), permissions); + boolean isTenantAdmin = authScopeService.isTenantAdmin(userId, activeTenantId); + LoginUser loginUser = new LoginUser(userId, activeTenantId, username, user.getIsPlatformAdmin(), isTenantAdmin, permissions); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 1695069..05dba1b 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -15,6 +15,14 @@ public final class RedisKeys { 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) { return "sys:param:" + paramKey; } diff --git a/backend/src/main/java/com/imeeting/controller/DictItemController.java b/backend/src/main/java/com/imeeting/controller/DictItemController.java index d754c40..a2eb120 100644 --- a/backend/src/main/java/com/imeeting/controller/DictItemController.java +++ b/backend/src/main/java/com/imeeting/controller/DictItemController.java @@ -55,6 +55,7 @@ public class DictItemController { } @GetMapping("/type/{typeCode}") +// @PreAuthorize("@ss.hasPermi('sys_dict:query')") public ApiResponse> getByType(@PathVariable String typeCode) { return ApiResponse.ok(sysDictItemService.getItemsByTypeCode(typeCode)); } diff --git a/backend/src/main/java/com/imeeting/controller/PermissionController.java b/backend/src/main/java/com/imeeting/controller/PermissionController.java index 82e911b..e00c3c5 100644 --- a/backend/src/main/java/com/imeeting/controller/PermissionController.java +++ b/backend/src/main/java/com/imeeting/controller/PermissionController.java @@ -1,11 +1,14 @@ package com.imeeting.controller; -import com.imeeting.auth.JwtTokenProvider; import com.imeeting.common.ApiResponse; import com.imeeting.dto.PermissionNode; 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 io.jsonwebtoken.Claims; +import com.imeeting.service.SysRoleService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -19,11 +22,19 @@ import java.util.Map; @RequestMapping("/api/permissions") public class PermissionController { 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.jwtTokenProvider = jwtTokenProvider; + this.sysRolePermissionMapper = sysRolePermissionMapper; + this.sysUserRoleMapper = sysUserRoleMapper; + this.sysRoleService = sysRoleService; + this.authVersionService = authVersionService; } @GetMapping @@ -80,6 +91,7 @@ public class PermissionController { @PutMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys:permission:update')") public ApiResponse update(@PathVariable Long id, @RequestBody SysPermission perm) { + List roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id); perm.setPermId(id); String error = validateParent(perm); if (error != null) { @@ -92,13 +104,21 @@ public class PermissionController { .eq(SysPermission::getPermId, id) .update(); } + if (updated) { + invalidateRoleUsers(roleIds); + } return ApiResponse.ok(updated); } @DeleteMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys:permission:delete')") public ApiResponse delete(@PathVariable Long id) { - return ApiResponse.ok(sysPermissionService.removeById(id)); + List roleIds = sysRolePermissionMapper.selectRoleIdsByPermId(id); + boolean removed = sysPermissionService.removeById(id); + if (removed) { + invalidateRoleUsers(roleIds); + } + return ApiResponse.ok(removed); } private Long getCurrentUserId() { @@ -191,4 +211,21 @@ public class PermissionController { node.setMeta(p.getMeta()); return node; } + + private void invalidateRoleUsers(List 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 userIds = sysUserRoleMapper.selectUserIdsByRoleId(roleId); + authVersionService.invalidateUsersTenantAuth(userIds, role.getTenantId()); + } + } } diff --git a/backend/src/main/java/com/imeeting/controller/RoleController.java b/backend/src/main/java/com/imeeting/controller/RoleController.java index 66b6231..2b4791e 100644 --- a/backend/src/main/java/com/imeeting/controller/RoleController.java +++ b/backend/src/main/java/com/imeeting/controller/RoleController.java @@ -9,10 +9,14 @@ import com.imeeting.entity.SysUser; import com.imeeting.entity.SysUserRole; import com.imeeting.mapper.SysRolePermissionMapper; import com.imeeting.mapper.SysUserRoleMapper; +import com.imeeting.service.AuthScopeService; +import com.imeeting.service.AuthVersionService; import com.imeeting.service.SysRoleService; import com.imeeting.service.SysUserService; import com.imeeting.service.SysPermissionService; +import com.imeeting.service.SysTenantUserService; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; @@ -28,15 +32,24 @@ public class RoleController { private final SysRolePermissionMapper sysRolePermissionMapper; private final SysUserRoleMapper sysUserRoleMapper; private final SysPermissionService sysPermissionService; + private final AuthScopeService authScopeService; + private final AuthVersionService authVersionService; + private final SysTenantUserService sysTenantUserService; public RoleController(SysRoleService sysRoleService, SysUserService sysUserService, SysRolePermissionMapper sysRolePermissionMapper, SysUserRoleMapper sysUserRoleMapper, - SysPermissionService sysPermissionService) { + SysPermissionService sysPermissionService, + AuthScopeService authScopeService, + AuthVersionService authVersionService, + SysTenantUserService sysTenantUserService) { this.sysRoleService = sysRoleService; this.sysUserService = sysUserService; this.sysRolePermissionMapper = sysRolePermissionMapper; this.sysUserRoleMapper = sysUserRoleMapper; this.sysPermissionService = sysPermissionService; + this.authScopeService = authScopeService; + this.authVersionService = authVersionService; + this.sysTenantUserService = sysTenantUserService; } @GetMapping @@ -48,19 +61,42 @@ public class RoleController { @GetMapping("/{id}/users") @PreAuthorize("@ss.hasPermi('sys:role:query')") public ApiResponse> 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)); } @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys:role:query')") public ApiResponse 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 @PreAuthorize("@ss.hasPermi('sys:role:create')") @Log(value = "新增角色", type = "角色管理") public ApiResponse 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)); } @@ -68,7 +104,15 @@ public class RoleController { @PreAuthorize("@ss.hasPermi('sys:role:update')") @Log(value = "修改角色", type = "角色管理") public ApiResponse 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.setTenantId(existing.getTenantId()); return ApiResponse.ok(sysRoleService.updateById(role)); } @@ -76,12 +120,34 @@ public class RoleController { @PreAuthorize("@ss.hasPermi('sys:role:delete')") @Log(value = "删除角色", type = "角色管理") public ApiResponse 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 userIds = sysUserRoleMapper.selectUserIdsByRoleId(id); + boolean removed = sysRoleService.removeById(id); + if (removed) { + authVersionService.invalidateUsersTenantAuth(userIds, existing.getTenantId()); + } + return ApiResponse.ok(removed); } @GetMapping("/{id}/permissions") @PreAuthorize("@ss.hasPermi('sys:role:permission:list')") public ApiResponse> listRolePermissions(@PathVariable Long id) { + SysRole targetRole = sysRoleService.getById(id); + if (targetRole == null) { + return ApiResponse.error("角色不存在"); + } + if (!canAccessTenant(targetRole.getTenantId())) { + return ApiResponse.error("禁止跨租户查看角色权限"); + } List rows = sysRolePermissionMapper.selectList( new QueryWrapper().eq("role_id", id) ); @@ -96,11 +162,15 @@ public class RoleController { @PostMapping("/{id}/permissions") @PreAuthorize("@ss.hasPermi('sys:role:permission:save')") + @Transactional(rollbackFor = Exception.class) public ApiResponse saveRolePermissions(@PathVariable Long id, @RequestBody PermissionBindingPayload payload) { List permIds = payload == null ? null : payload.getPermIds(); // 权限越权校验 Long currentTenantId = getCurrentTenantId(); + if (currentTenantId == null) { + return ApiResponse.error("Tenant ID required"); + } SysRole targetRole = sysRoleService.getById(id); if (targetRole == null) { return ApiResponse.error("角色不存在"); @@ -108,12 +178,15 @@ public class RoleController { // 关键校验:只有平台管理员可以修改 TENANT_ADMIN 角色的权限 if ("TENANT_ADMIN".equalsIgnoreCase(targetRole.getRoleCode())) { - if (!Long.valueOf(0).equals(currentTenantId)) { + if (!authScopeService.isCurrentPlatformAdmin()) { return ApiResponse.error("租户管理员角色的权限只能由平台管理员修改"); } } + if (!canAccessTenant(targetRole.getTenantId())) { + return ApiResponse.error("禁止跨租户修改角色权限"); + } - if (!Long.valueOf(0).equals(currentTenantId)) { + if (!authScopeService.isCurrentPlatformAdmin()) { List myPerms = sysPermissionService.listByUserId(getCurrentUserId(), currentTenantId); Set myPermIds = myPerms.stream() @@ -131,6 +204,7 @@ public class RoleController { sysRolePermissionMapper.delete(new QueryWrapper().eq("role_id", id)); if (permIds == null || permIds.isEmpty()) { + authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId()); return ApiResponse.ok(true); } for (Long permId : permIds) { @@ -142,25 +216,53 @@ public class RoleController { item.setPermId(permId); sysRolePermissionMapper.insert(item); } + authVersionService.invalidateUsersTenantAuth(sysUserRoleMapper.selectUserIdsByRoleId(id), targetRole.getTenantId()); return ApiResponse.ok(true); } @PostMapping("/{id}/users") @PreAuthorize("@ss.hasPermi('sys:role:update')") @Log(value = "角色关联用户", type = "角色管理") + @Transactional(rollbackFor = Exception.class) public ApiResponse bindUsers(@PathVariable Long id, @RequestBody UserBindingPayload payload) { if (payload == null || payload.getUserIds() == null) { 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 toInsertUserIds = new ArrayList<>(); for (Long userId : payload.getUserIds()) { - QueryWrapper qw = new QueryWrapper<>(); - qw.eq("role_id", id).eq("user_id", userId); - if (sysUserRoleMapper.selectCount(qw) == 0) { - SysUserRole ur = new SysUserRole(); - ur.setRoleId(id); - ur.setUserId(userId); - sysUserRoleMapper.insert(ur); + if (userId == null) { + continue; } + QueryWrapper qw = new QueryWrapper<>(); + qw.eq("role_id", id).eq("user_id", userId).eq("tenant_id", role.getTenantId()); + if (sysUserRoleMapper.selectCount(qw) == 0) { + boolean hasMembership = sysTenantUserService.count( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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(); + ur.setTenantId(role.getTenantId()); + ur.setRoleId(id); + ur.setUserId(userId); + sysUserRoleMapper.insert(ur); + authVersionService.invalidateUserTenantAuth(userId, role.getTenantId()); } return ApiResponse.ok(true); } @@ -168,10 +270,19 @@ public class RoleController { @DeleteMapping("/{id}/users/{userId}") @PreAuthorize("@ss.hasPermi('sys:role:update')") @Log(value = "角色取消关联用户", type = "角色管理") + @Transactional(rollbackFor = Exception.class) public ApiResponse 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 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); + authVersionService.invalidateUserTenantAuth(userId, role.getTenantId()); return ApiResponse.ok(true); } @@ -191,6 +302,17 @@ public class RoleController { 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 { private List userIds; public List getUserIds() { return userIds; } diff --git a/backend/src/main/java/com/imeeting/controller/UserController.java b/backend/src/main/java/com/imeeting/controller/UserController.java index 35b00e2..796b08b 100644 --- a/backend/src/main/java/com/imeeting/controller/UserController.java +++ b/backend/src/main/java/com/imeeting/controller/UserController.java @@ -1,6 +1,5 @@ package com.imeeting.controller; -import com.imeeting.auth.JwtTokenProvider; import com.imeeting.common.ApiResponse; import com.imeeting.dto.PasswordUpdateDTO; 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.SysUserRole; import com.imeeting.mapper.SysUserRoleMapper; +import com.imeeting.service.AuthScopeService; +import com.imeeting.service.AuthVersionService; import com.imeeting.service.SysUserService; import com.imeeting.common.annotation.Log; -import io.jsonwebtoken.Claims; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; @@ -27,21 +28,25 @@ import java.util.List; public class UserController { private final SysUserService sysUserService; private final PasswordEncoder passwordEncoder; - private final JwtTokenProvider jwtTokenProvider; private final SysUserRoleMapper sysUserRoleMapper; private final com.imeeting.service.SysTenantUserService sysTenantUserService; private final com.imeeting.service.SysRoleService sysRoleService; + private final AuthScopeService authScopeService; + private final AuthVersionService authVersionService; public UserController(SysUserService sysUserService, PasswordEncoder passwordEncoder, - JwtTokenProvider jwtTokenProvider, SysUserRoleMapper sysUserRoleMapper, + SysUserRoleMapper sysUserRoleMapper, com.imeeting.service.SysTenantUserService sysTenantUserService, - com.imeeting.service.SysRoleService sysRoleService) { + com.imeeting.service.SysRoleService sysRoleService, + AuthScopeService authScopeService, + AuthVersionService authVersionService) { this.sysUserService = sysUserService; this.passwordEncoder = passwordEncoder; - this.jwtTokenProvider = jwtTokenProvider; this.sysUserRoleMapper = sysUserRoleMapper; this.sysTenantUserService = sysTenantUserService; this.sysRoleService = sysRoleService; + this.authScopeService = authScopeService; + this.authVersionService = authVersionService; } @GetMapping @@ -49,11 +54,12 @@ public class UserController { public ApiResponse> list(@RequestParam(required = false) Long tenantId, @RequestParam(required = false) Long orgId) { Long currentTenantId = getCurrentTenantId(); List users; + Long targetTenantId = null; if (Long.valueOf(0).equals(currentTenantId) && tenantId == null) { users = sysUserService.list(); } else { - Long targetTenantId = tenantId != null ? tenantId : currentTenantId; + targetTenantId = tenantId != null ? tenantId : currentTenantId; if (targetTenantId == null) { return ApiResponse.error("Tenant ID required"); } @@ -66,9 +72,11 @@ public class UserController { user.setMemberships(sysTenantUserService.listByUserId(user.getUserId())); // 加载角色信息 - List userRoles = sysUserRoleMapper.selectList( - new QueryWrapper().eq("user_id", user.getUserId()) - ); + QueryWrapper roleQuery = new QueryWrapper().eq("user_id", user.getUserId()); + if (targetTenantId != null) { + roleQuery.eq("tenant_id", targetTenantId); + } + List userRoles = sysUserRoleMapper.selectList(roleQuery); if (userRoles != null && !userRoles.isEmpty()) { List roleIds = userRoles.stream() .map(SysUserRole::getRoleId) @@ -102,6 +110,7 @@ public class UserController { profile.setStatus(user.getStatus()); profile.setAdmin(userId == 1L); profile.setIsPlatformAdmin(user.getIsPlatformAdmin()); + profile.setIsTenantAdmin(loginUser.getIsTenantAdmin()); profile.setPwdResetRequired(user.getPwdResetRequired()); return ApiResponse.ok(profile); } @@ -109,6 +118,13 @@ public class UserController { @GetMapping("/{id}") @PreAuthorize("@ss.hasPermi('sys:user:query')") public ApiResponse 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); if (user != null) { user.setMemberships(sysTenantUserService.listByUserId(id)); @@ -129,6 +145,9 @@ public class UserController { @Log(value = "新增用户", type = "用户管理") public ApiResponse create(@RequestBody SysUser user) { Long currentTenantId = getCurrentTenantId(); + if (currentTenantId == null) { + return ApiResponse.error("Tenant ID required"); + } // 非平台管理员强制设置为当前租户 if (!Long.valueOf(0).equals(currentTenantId)) { if (user.getMemberships() != null && !user.getMemberships().isEmpty()) { @@ -158,7 +177,13 @@ public class UserController { @Log(value = "修改用户", type = "用户管理") public ApiResponse update(@PathVariable Long id, @RequestBody SysUser user) { Long currentTenantId = getCurrentTenantId(); + if (currentTenantId == null) { + return ApiResponse.error("Tenant ID required"); + } user.setUserId(id); + if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) { + return ApiResponse.error("禁止跨租户修改用户"); + } // 非平台管理员强制约束租户身份 if (!Long.valueOf(0).equals(currentTenantId)) { @@ -181,6 +206,13 @@ public class UserController { @PreAuthorize("@ss.hasPermi('sys:user:delete')") @Log(value = "删除用户", type = "用户管理") public ApiResponse 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)); } @@ -222,9 +254,18 @@ public class UserController { @GetMapping("/{id}/roles") @PreAuthorize("@ss.hasPermi('sys:user:role:list')") public ApiResponse> listUserRoles(@PathVariable Long id) { - List rows = sysUserRoleMapper.selectList( - new QueryWrapper().eq("user_id", id) - ); + Long currentTenantId = getCurrentTenantId(); + if (currentTenantId == null) { + return ApiResponse.error("Tenant ID required"); + } + if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) { + return ApiResponse.error("禁止跨租户查看用户角色"); + } + QueryWrapper query = new QueryWrapper().eq("user_id", id); + if (!authScopeService.isCurrentPlatformAdmin()) { + query.eq("tenant_id", currentTenantId); + } + List rows = sysUserRoleMapper.selectList(query); List roleIds = new ArrayList<>(); for (SysUserRole row : rows) { if (row.getRoleId() != null) { @@ -236,31 +277,84 @@ public class UserController { @PostMapping("/{id}/roles") @PreAuthorize("@ss.hasPermi('sys:user:role:save')") + @Transactional(rollbackFor = Exception.class) public ApiResponse saveUserRoles(@PathVariable Long id, @RequestBody RoleBindingPayload payload) { - List roleIds = payload == null ? null : payload.getRoleIds(); - sysUserRoleMapper.delete(new QueryWrapper().eq("user_id", id)); - if (roleIds == null || roleIds.isEmpty()) { - return ApiResponse.ok(true); + Long currentTenantId = getCurrentTenantId(); + if (currentTenantId == null) { + return ApiResponse.error("Tenant ID required"); } - for (Long roleId : roleIds) { - if (roleId == null) { - continue; + if (!authScopeService.isCurrentPlatformAdmin() && !isUserInTenant(id, currentTenantId)) { + return ApiResponse.error("禁止跨租户分配角色"); + } + + List roleIds = payload == null ? null : payload.getRoleIds(); + + List rolesToBind = new ArrayList<>(); + if (roleIds != null) { + for (Long roleId : roleIds) { + if (roleId == null) { + 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() + .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 scopeQuery = new QueryWrapper().eq("user_id", id); + if (!authScopeService.isCurrentPlatformAdmin()) { + scopeQuery.eq("tenant_id", currentTenantId); + } + List existingRows = sysUserRoleMapper.selectList(scopeQuery); + java.util.Set 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(); + item.setTenantId(role.getTenantId()); item.setUserId(id); - item.setRoleId(roleId); + item.setRoleId(role.getRoleId()); sysUserRoleMapper.insert(item); } + for (Long tenantId : affectedTenantIds) { + authVersionService.invalidateUserTenantAuth(id, tenantId); + } return ApiResponse.ok(true); } - private Long resolveUserId(String authorization) { - if (authorization == null || !authorization.startsWith("Bearer ")) { - return null; + private boolean isUserInTenant(Long userId, Long tenantId) { + if (userId == null || tenantId == null) { + return false; } - String token = authorization.substring(7); - Claims claims = jwtTokenProvider.parseToken(token); - return claims.get("userId", Long.class); + return sysTenantUserService.count( + new LambdaQueryWrapper() + .eq(com.imeeting.entity.SysTenantUser::getUserId, userId) + .eq(com.imeeting.entity.SysTenantUser::getTenantId, tenantId) + ) > 0; } public static class RoleBindingPayload { diff --git a/backend/src/main/java/com/imeeting/dto/UserProfile.java b/backend/src/main/java/com/imeeting/dto/UserProfile.java index 824606f..5211756 100644 --- a/backend/src/main/java/com/imeeting/dto/UserProfile.java +++ b/backend/src/main/java/com/imeeting/dto/UserProfile.java @@ -14,5 +14,6 @@ public class UserProfile { @JsonProperty("isAdmin") private boolean isAdmin; private Boolean isPlatformAdmin; + private Boolean isTenantAdmin; private Integer pwdResetRequired; } diff --git a/backend/src/main/java/com/imeeting/entity/SysUserRole.java b/backend/src/main/java/com/imeeting/entity/SysUserRole.java index 75cb834..d417af3 100644 --- a/backend/src/main/java/com/imeeting/entity/SysUserRole.java +++ b/backend/src/main/java/com/imeeting/entity/SysUserRole.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -14,8 +15,11 @@ import java.time.LocalDateTime; public class SysUserRole { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long tenantId; private Long userId; private Long roleId; + @TableLogic(value = "0", delval = "1") + private Integer isDeleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java index 737d2a4..d9c1490 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysPermissionMapper.java @@ -14,10 +14,15 @@ public interface SysPermissionMapper extends BaseMapper { @Select(""" SELECT DISTINCT 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_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 selectByUserId(@Param("userId") Long userId, @Param("tenantId") Long tenantId); } diff --git a/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java b/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java index 8bf271e..087169d 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysRolePermissionMapper.java @@ -2,7 +2,18 @@ package com.imeeting.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imeeting.entity.SysRolePermission; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Mapper; +import java.util.List; + @Mapper -public interface SysRolePermissionMapper extends BaseMapper {} +public interface SysRolePermissionMapper extends BaseMapper { + @Select(""" + SELECT DISTINCT role_id + FROM sys_role_permission + WHERE perm_id = #{permId} + """) + List selectRoleIdsByPermId(@Param("permId") Long permId); +} diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java index dd3d48d..bf1a932 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserMapper.java @@ -10,7 +10,14 @@ import java.util.List; @Mapper public interface SysUserMapper extends BaseMapper { - @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 selectUsersByRoleId(@Param("roleId") Long roleId); @InterceptorIgnore(tenantLine = "true") diff --git a/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java index 5ccfaf7..e6766d3 100644 --- a/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java +++ b/backend/src/main/java/com/imeeting/mapper/SysUserRoleMapper.java @@ -2,7 +2,41 @@ package com.imeeting.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.imeeting.entity.SysUserRole; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Mapper; +import java.util.List; + @Mapper -public interface SysUserRoleMapper extends BaseMapper {} +public interface SysUserRoleMapper extends BaseMapper { + @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 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 selectRoleIdsByUserIdAndTenantId(@Param("userId") Long userId, @Param("tenantId") Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/security/LoginUser.java b/backend/src/main/java/com/imeeting/security/LoginUser.java index 15cb4ff..34ba21b 100644 --- a/backend/src/main/java/com/imeeting/security/LoginUser.java +++ b/backend/src/main/java/com/imeeting/security/LoginUser.java @@ -20,6 +20,7 @@ public class LoginUser implements UserDetails { private String username; private String displayName; private Boolean isPlatformAdmin; + private Boolean isTenantAdmin; private Set permissions; @Override diff --git a/backend/src/main/java/com/imeeting/security/PermissionService.java b/backend/src/main/java/com/imeeting/security/PermissionService.java index 5a1cbdb..814558e 100644 --- a/backend/src/main/java/com/imeeting/security/PermissionService.java +++ b/backend/src/main/java/com/imeeting/security/PermissionService.java @@ -24,11 +24,10 @@ public class PermissionService { if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser)) { return false; } - LoginUser loginUser = (LoginUser) authentication.getPrincipal(); - - // 超级管理员(ID=1) 拥有所有权限 - if (loginUser.getUserId() != null && loginUser.getUserId() == 1L) { + // 平台管理员在系统租户(0)下放行全部权限点 + if (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) + && Long.valueOf(0L).equals(loginUser.getTenantId())) { return true; } diff --git a/backend/src/main/java/com/imeeting/service/AuthScopeService.java b/backend/src/main/java/com/imeeting/service/AuthScopeService.java new file mode 100644 index 0000000..7a2f7ed --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/AuthScopeService.java @@ -0,0 +1,9 @@ +package com.imeeting.service; + +public interface AuthScopeService { + boolean isCurrentPlatformAdmin(); + + boolean isCurrentTenantAdmin(); + + boolean isTenantAdmin(Long userId, Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/AuthVersionService.java b/backend/src/main/java/com/imeeting/service/AuthVersionService.java new file mode 100644 index 0000000..b357393 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/AuthVersionService.java @@ -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 userIds, Long tenantId); +} diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthScopeServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthScopeServiceImpl.java new file mode 100644 index 0000000..1d7b1f1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/AuthScopeServiceImpl.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java index c2de7c0..639113c 100644 --- a/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/AuthServiceImpl.java @@ -33,6 +33,7 @@ public class AuthServiceImpl implements AuthService { private final StringRedisTemplate stringRedisTemplate; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final AuthVersionService authVersionService; private final SysLogService sysLogService; private final HttpServletRequest httpServletRequest; @@ -50,7 +51,8 @@ public class AuthServiceImpl implements AuthService { StringRedisTemplate stringRedisTemplate, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, - SysLogService sysLogService, + AuthVersionService authVersionService, + SysLogService sysLogService, HttpServletRequest httpServletRequest) { this.sysUserService = sysUserService; this.sysUserMapper = sysUserMapper; @@ -59,6 +61,7 @@ public class AuthServiceImpl implements AuthService { this.stringRedisTemplate = stringRedisTemplate; this.passwordEncoder = passwordEncoder; this.jwtTokenProvider = jwtTokenProvider; + this.authVersionService = authVersionService; this.sysLogService = sysLogService; this.httpServletRequest = httpServletRequest; } @@ -175,6 +178,11 @@ public class AuthServiceImpl implements AuthService { Long userId = claims.get("userId", Long.class); Long tenantId = claims.get("tenantId", Long.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)); if (cached == null || !cached.equals(refreshToken)) { throw new IllegalArgumentException("刷新令牌已失效"); @@ -298,6 +306,7 @@ public class AuthServiceImpl implements AuthService { } private TokenResponse issueTokens(SysUser user, Long tenantId, String deviceCode, long accessMinutes, long refreshDays) { + long authVersion = authVersionService.getVersion(user.getUserId(), tenantId); Map accessClaims = new HashMap<>(); accessClaims.put("tokenType", "access"); accessClaims.put("userId", user.getUserId()); @@ -305,13 +314,14 @@ public class AuthServiceImpl implements AuthService { accessClaims.put("username", user.getUsername()); accessClaims.put("displayName", user.getDisplayName()); accessClaims.put("deviceCode", deviceCode); - accessClaims.put("pwdResetRequired", user.getPwdResetRequired()); + accessClaims.put("authVersion", authVersion); Map refreshClaims = new HashMap<>(); refreshClaims.put("tokenType", "refresh"); refreshClaims.put("userId", user.getUserId()); refreshClaims.put("tenantId", tenantId); refreshClaims.put("deviceCode", deviceCode); + refreshClaims.put("authVersion", authVersion); String access = jwtTokenProvider.createToken(accessClaims, Duration.ofMinutes(accessMinutes).toMillis()); String refresh = jwtTokenProvider.createToken(refreshClaims, Duration.ofDays(refreshDays).toMillis()); diff --git a/backend/src/main/java/com/imeeting/service/impl/AuthVersionServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/AuthVersionServiceImpl.java new file mode 100644 index 0000000..6de6307 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/impl/AuthVersionServiceImpl.java @@ -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 userIds, Long tenantId) { + if (userIds == null || userIds.isEmpty() || tenantId == null) { + return; + } + for (Long userId : userIds) { + invalidateUserTenantAuth(userId, tenantId); + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java index 7293ebe..13ca541 100644 --- a/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/impl/SysTenantServiceImpl.java @@ -175,6 +175,7 @@ public class SysTenantServiceImpl extends ServiceImpl