Commit eaad4a3a by DaiJiezhang

fix: align auth session and device write security

parent cfd24c73
......@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.*;
import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
......@@ -15,26 +16,26 @@ import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/device-assets")
public class DeviceAssetController {
private final DeviceAssetService service;
private final DeviceAssetService service; private final PagePermissionService permissions;
/** 代码作用(白话):接收设备业务服务。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):HTTP Controller -> Service -> Mapper/文件服务。 */
public DeviceAssetController(DeviceAssetService service) { this.service=service; }
public DeviceAssetController(DeviceAssetService service, PagePermissionService permissions) { this.service=service; this.permissions=permissions; }
/** 代码作用(白话):返回设备分页列表。关联文件:DeviceAssetPageQuery.java、DeviceAssetService.java。关联逻辑(调用链/数据流):GET 参数 -> page -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { return ApiResponse.success(service.page(query)); }
@GetMapping public ApiResponse<DeviceAssetPageResponse> page(@Valid DeviceAssetPageQuery query) { permissions.requireAdministrator(); return ApiResponse.success(service.page(query)); }
/** 代码作用(白话):创建设备及可选图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart POST -> create -> 文件/数据库 -> JSON 响应。 */
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("新增成功",service.create(request)); }
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> create(@Valid @ModelAttribute DeviceAssetSaveRequest request) { permissions.requireAdministrator(); return ApiResponse.success("新增成功",service.create(request)); }
/** 代码作用(白话):编辑有效设备并可替换或移除图片。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart PUT -> update -> 文件/数据库 -> JSON 响应。 */
@PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { return ApiResponse.success("编辑成功",service.update(id,request)); }
@PutMapping(value="/{id}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse<DeviceAssetResponse> update(@PathVariable Long id,@Valid @ModelAttribute DeviceAssetSaveRequest request) { permissions.requireAdministrator(); return ApiResponse.success("编辑成功",service.update(id,request)); }
/** 代码作用(白话):在没有有效引用时软删除设备。关联文件:DeviceAssetService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):DELETE -> 引用检查 -> deleteTime 更新 -> 前端刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id) { service.softDelete(id); return ApiResponse.success("删除成功",null); }
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id) { permissions.requireAdministrator(); service.softDelete(id); return ApiResponse.success("删除成功",null); }
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { return ApiResponse.success(service.searchCompanyPersons(keyword)); }
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { permissions.requireAdministrator(); Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); }
}
......@@ -38,6 +38,13 @@ public class PagePermissionService {
try { return objectMapper.writeValueAsString(normalized); } catch (Exception error) { throw new IllegalStateException("页面权限无法保存", error); }
}
/** Plain purpose: convert a saved permission JSON value into the same validated form used for a new account request. Related files: SystemUserAdminService.java, SystemUserEntity.java. Flow: stored permission JSON -> normalized map -> role-change comparison -> auth-version decision. */
public String normalizeStoredPermissions(String storedPermissions) {
if (storedPermissions == null || storedPermissions.isBlank()) return validatePermissions(Map.of());
try { return validatePermissions(objectMapper.readValue(storedPermissions, new TypeReference<>() {})); }
catch (Exception ignored) { return validatePermissions(Map.of()); }
}
/** 代码作用(白话):在资产接口执行服务端最低权限检查;关联文件:PhoneAssetController.java、WecomAccountController.java。关联逻辑(调用链/数据流):Controller -> 当前认证身份 -> READ/EDIT 决定 403 或继续业务服务。 */
public void require(String pageKey, String minimum) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
......@@ -46,6 +53,12 @@ public class PagePermissionService {
if (!("EDIT".equals(permission) || ("READ".equals(permission) && "READ".equals(minimum)))) throw new AccessDeniedException("没有页面权限");
}
/** Plain purpose: stop non-administrators before an administrator-only controller reaches business data. Related files: DeviceAssetController.java, AuthTokenFilter.java. Flow: authenticated token principal -> administrator role check -> device controller or 403 response. */
public void requireAdministrator() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof AuthPrincipal principal) || !isAdministrator(principal.roleCode())) throw new AccessDeniedException("没有权限");
}
/** 代码作用(白话):判断角色是否固定拥有全部编辑权限;关联文件:SystemUserAdminService.java。关联逻辑(调用链/数据流):role_code -> 全量权限或逐页权限。 */
public boolean isAdministrator(String roleCode) { return "DEVELOPER".equals(roleCode) || "SUPER_ADMIN".equals(roleCode); }
/** 代码作用(白话):限制权限值为三档,避免未知值意外放行;关联文件:UserPermissionView.js。关联逻辑(调用链/数据流):前端单选值 -> 后端验证 -> 持久化。 */
......
......@@ -24,7 +24,13 @@ public class SystemUserAdminService {
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件:SystemUserCreateRequest.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):创建表单 -> 创建者校验 -> 密码/禁用规则 -> as_system_user。 */
public SystemUserResponse createUser(SystemUserCreateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); if (users.selectCount(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getUsername, request.username()).eq(SystemUserEntity::getDeleteTime, 0L)) > 0) throw new IllegalArgumentException("用户名已存在"); SystemUserEntity user = new SystemUserEntity(); user.setUsername(request.username()); user.setRoleCode(request.roleCode()); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setDeleteTime(0L); user.setCreateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now()); user.setAuthVersion(1); if ("SUPER_ADMIN".equals(actor)) { user.setStatus("DISABLED"); user.setPasswordHash(null); } else { requireValidPassword(request.password(), request.username()); user.setStatus("ACTIVE"); user.setPasswordHash(encoder.encode(request.password())); } users.insert(user); return responseOf(user); }
/** 代码作用(白话):更新非开发者账号的角色、启停和逐页权限,禁止绕过 Jeddy;关联文件:SystemUserUpdateRequest.java、PagePermissionService.java。关联逻辑(调用链/数据流):编辑面板 -> 角色边界 -> 用户表更新 -> 下次请求按新权限判定。 */
public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); if (!("ACTIVE".equals(user.getStatus()) || "DISABLED".equals(user.getStatus()))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(user.getStatus()) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); user.setPagePermissions(permissions.validatePermissions(request.pagePermissions())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); }
public SystemUserResponse updateUser(Long id, SystemUserUpdateRequest request) { String actor = currentRole(); assertCreatableRole(actor, request.roleCode()); SystemUserEntity user = findManageableUser(id); validateRequestedStatus(request.status(), user); String normalizedPermissions = permissions.validatePermissions(request.pagePermissions()); boolean authorizationChanged = authorizationStateChanged(user, request, normalizedPermissions); user.setRoleCode(request.roleCode()); user.setStatus(request.status()); user.setPagePermissions(normalizedPermissions); if (authorizationChanged) user.setAuthVersion(nextAuthVersion(user.getAuthVersion())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); return responseOf(user); }
/** Plain purpose: decide whether an account edit changes authorization facts embedded in its existing login token. Related files: PagePermissionService.java, AuthTokenFilter.java. Flow: saved account/request -> normalized authorization comparison -> auth-version increment or session retention. */
private boolean authorizationStateChanged(SystemUserEntity user, SystemUserUpdateRequest request, String normalizedPermissions) { return !java.util.Objects.equals(user.getRoleCode(), request.roleCode()) || !java.util.Objects.equals(user.getStatus(), request.status()) || !java.util.Objects.equals(permissions.normalizeStoredPermissions(user.getPagePermissions()), normalizedPermissions); }
/** Plain purpose: return the next usable login-ticket version even for a historical row with a null version. Related files: SystemUserEntity.java, AuthTokenFilter.java. Flow: authorization change -> incremented authVersion -> old JWT rejected on next request. */
private int nextAuthVersion(Integer currentVersion) { return (currentVersion == null ? 1 : currentVersion) + 1; }
/** Plain purpose: reject invalid status transitions before changing an account entity. Related files: SystemUserUpdateRequest.java, SystemUserAdminService.java. Flow: update request -> status validation -> safe entity update or readable failure. */
private void validateRequestedStatus(String status, SystemUserEntity user) { if (!("ACTIVE".equals(status) || "DISABLED".equals(status))) throw new IllegalArgumentException("账号状态无效"); if ("ACTIVE".equals(status) && user.getPasswordHash() == null) throw new IllegalArgumentException("请由开发者先设置密码后再启用账号"); }
/** 代码作用(白话):仅允许开发者为非开发者账号设置密码,并交给数据库触发器记录时间及失效旧会话;关联文件:PasswordResetRequest.java、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):开发者表单 -> BCrypt hash -> UPDATE password_hash -> trigger(auth_version)。 */
public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password(), user.getUsername()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); }
/** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */
......
package com.xyw.console.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.xyw.console.auth.AuthTokenFilter; import com.xyw.console.common.ApiResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfException;
@Configuration @EnableWebSecurity
public class SecurityConfig {
/** 代码作用(白话):配置无状态认证、CSRF 和统一 401/403 响应;关联文件:AuthTokenFilter.java、WebConfig.java。关联逻辑(调用链/数据流):浏览器请求 -> Security Filter Chain -> Cookie 验签 -> Controller。 */
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,"没有权限"))).build(); }
@Bean SecurityFilterChain securityFilterChain(HttpSecurity http, AuthTokenFilter filter, ObjectMapper mapper) throws Exception { return http.httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable).csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).ignoringRequestMatchers("/api/auth/login")).cors(cors -> {}).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.OPTIONS,"/**").permitAll().requestMatchers("/api/auth/login", "/api/auth/csrf").permitAll().anyRequest().authenticated()).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class).exceptionHandling(errors -> errors.authenticationEntryPoint((request,response,error)->write(mapper,response,401,"请先登录")).accessDeniedHandler((request,response,error)->write(mapper,response,403,accessDeniedMessage(error)))).build(); }
/** Plain purpose: choose a safe client action for CSRF failures without exposing account or token details. Related files: auth-api-client.js, SecurityConfigTest.java. Flow: Spring access denial -> safe message -> JSON response -> browser refresh/retry guidance. */
String accessDeniedMessage(Exception error) { return error instanceof CsrfException ? "安全校验已失效,请刷新页面后重试" : "没有权限"; }
/** 代码作用(白话):生成 BCrypt 编码器,确保密码只存不可逆哈希;关联文件:AuthService.java。关联逻辑(调用链/数据流):开发者设密码/登录验证 -> BCrypt。 */
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
/** 代码作用(白话):把安全层错误也保持为现有 code/message/data 格式;关联文件:ApiResponse.java、前端 API 客户端。关联逻辑(调用链/数据流):安全拒绝 -> JSON 响应 -> 页面错误提示。 */
......
......@@ -5,6 +5,8 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
......@@ -15,11 +17,18 @@ import com.xyw.console.asset.dto.DeviceAssetPageResponse;
import com.xyw.console.asset.dto.DeviceAssetResponse;
import com.xyw.console.asset.exception.DeviceAssetValidationException;
import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.asset.dto.DeviceAssetPageQuery;
import com.xyw.console.asset.dto.DeviceAssetSaveRequest;
import com.xyw.console.auth.AuthPrincipal;
import com.xyw.console.auth.PagePermissionService;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpMethod;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
......@@ -57,8 +66,23 @@ class DeviceAssetControllerTest {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
mockMvc(service).perform(get("/api/device-assets")).andExpect(status().isOk()).andExpect(jsonPath("$.data.records[0].deleteTime").doesNotExist()).andExpect(jsonPath("$.data.records[0].imageAttachment1Url").value("/api/device-assets/files/image.png")).andExpect(jsonPath("$.data.records[0].imageAttachment1").doesNotExist());
}
/** Plain purpose: prove a logged-in ordinary role cannot bypass the sidebar and call any device endpoint directly. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: finance principal -> controller administrator check -> access denial before device service or image read. */
@Test void blocksNonAdministratorsFromEveryDeviceEndpoint() {
DeviceAssetService service = mock(DeviceAssetService.class); PagePermissionService permissions = new PagePermissionService(new com.fasterxml.jackson.databind.ObjectMapper()); DeviceAssetController controller = new DeviceAssetController(service, permissions);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(new AuthPrincipal(3L, "finance", "FINANCE", 1), null));
try {
assertThrows(AccessDeniedException.class, () -> controller.page(new DeviceAssetPageQuery(1, 20, null, null, null, null)));
assertThrows(AccessDeniedException.class, () -> controller.create(new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png"));
verifyNoInteractions(service);
} finally { SecurityContextHolder.clearContext(); }
}
/** 代码作用(白话):集中创建带设备异常转换器的 MockMvc,保证控制器返回契约可被测试。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):HTTP 模拟请求 -> Controller -> Advice -> JSON 响应。*/
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service)).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);}
}
package com.xyw.console.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xyw.console.asset.entity.SystemUserEntity;
import com.xyw.console.asset.mapper.SystemUserMapper;
import com.xyw.console.auth.dto.SystemUserCreateRequest;
import com.xyw.console.auth.dto.SystemUserUpdateRequest;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** File purpose (plain language): verifies that account authorization changes revoke old login versions while existing role boundaries remain enforced. */
class SystemUserAdminServiceTest {
private SystemUserMapper users;
private SystemUserAdminService service;
/** Plain purpose: build a developer-authenticated service fixture before each account-security test. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: developer principal -> SecurityContext -> service role boundary -> mocked user update. */
@BeforeEach void setUp() {
users = mock(SystemUserMapper.class);
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), mock(PasswordEncoder.class));
authenticate("DEVELOPER");
}
/** Plain purpose: remove the test principal so it cannot influence the next security test. Related files: AuthTokenFilter.java, SystemUserAdminService.java. Flow: test completion -> SecurityContext clear -> isolated next request. */
@AfterEach void tearDown() { SecurityContextHolder.clearContext(); }
/** Plain purpose: prove that promotion changes the account version used to reject its old token. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: role edit -> authVersion increment -> next old-token request rejected. */
@Test void promotionIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("SUPER_ADMIN", "ACTIVE", Map.of("phone-assets", "READ")));
assertEquals(5, user.getAuthVersion());
verify(users).updateById(user);
}
/** Plain purpose: prove that disabling an account revokes its prior session even when the role stays the same. Related files: SystemUserAdminService.java, AuthTokenFilter.java. Flow: status edit -> authVersion increment -> disabled account cannot keep old session. */
@Test void statusChangeIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of());
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "DISABLED", Map.of()));
assertEquals(5, user.getAuthVersion());
}
/** Plain purpose: prove that changed page permissions invalidate a token that still carries the previous authorization state. Related files: PagePermissionService.java, SystemUserAdminService.java. Flow: permission edit -> normalized comparison -> authVersion increment. */
@Test void permissionChangeIncrementsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "ACTIVE", Map.of("phone-assets", "EDIT")));
assertEquals(5, user.getAuthVersion());
}
/** Plain purpose: prove that saving unchanged authorization facts does not unnecessarily sign the account out. Related files: SystemUserAdminService.java, PagePermissionService.java. Flow: no-op edit -> equal normalized values -> retained authVersion. */
@Test void noOpUpdateRetainsAuthVersion() {
SystemUserEntity user = activeUser("FINANCE", 4, Map.of("phone-assets", "READ"));
when(users.selectOne(any())).thenReturn(user);
service.updateUser(9L, new SystemUserUpdateRequest("FINANCE", "ACTIVE", Map.of("phone-assets", "READ")));
assertEquals(4, user.getAuthVersion());
}
/** Plain purpose: prove that a fresh developer principal can still create an allowed non-developer account. Related files: SystemUserAdminService.java, SystemUserCreateRequest.java. Flow: developer principal -> create boundary -> mapper insert -> safe response. */
@Test void developerCanCreateSuperAdministrator() {
when(users.selectCount(any())).thenReturn(0L);
PasswordEncoder encoder = mock(PasswordEncoder.class);
when(encoder.encode(any())).thenReturn("hash");
service = new SystemUserAdminService(users, new PagePermissionService(new ObjectMapper()), encoder);
service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of()));
verify(users).insert(any(SystemUserEntity.class));
}
/** Plain purpose: prove that an ordinary role cannot manage accounts even if it calls the service directly. Related files: SystemUserAdminService.java, SystemUserAdminController.java. Flow: finance principal -> role boundary -> access denial -> no mapper write. */
@Test void nonAdministratorCannotCreateAccounts() {
authenticate("FINANCE");
assertThrows(AccessDeniedException.class, () -> service.createUser(new SystemUserCreateRequest("admin_two", "SUPER_ADMIN", "strong-password-123", Map.of())));
verifyNoInteractions(users);
}
/** Plain purpose: install a minimal signed-token equivalent principal for one service test. Related files: AuthPrincipal.java, AuthTokenFilter.java. Flow: role code -> SecurityContext principal -> SystemUserAdminService authorization branch. */
private void authenticate(String roleCode) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(new AuthPrincipal(1L, "tester", roleCode, 1), null)); }
/** Plain purpose: create a persisted account shape with a validated permission map and active credentials. Related files: SystemUserEntity.java, PagePermissionService.java. Flow: test fixture -> mapper select result -> authorization-state comparison. */
private SystemUserEntity activeUser(String roleCode, int authVersion, Map<String, String> permissions) {
SystemUserEntity user = new SystemUserEntity();
user.setId(9L); user.setUsername("managed_user"); user.setRoleCode(roleCode); user.setStatus("ACTIVE"); user.setPasswordHash("hash"); user.setDeleteTime(0L); user.setAuthVersion(authVersion);
user.setPagePermissions(new PagePermissionService(new ObjectMapper()).validatePermissions(permissions));
return user;
}
}
package com.xyw.console.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
/** File purpose (plain language): verifies that security rejections provide safe browser actions without leaking account or token details. */
class SecurityConfigTest {
/** Plain purpose: verify a missing or invalid CSRF token receives refresh-and-retry guidance rather than an indistinguishable role error. Related files: SecurityConfig.java, auth-api-client.js. Flow: CSRF filter denial -> safe message -> browser refresh/retry. */
@Test void csrfFailureUsesRefreshAndRetryMessage() {
SecurityConfig config = new SecurityConfig();
assertEquals("安全校验已失效,请刷新页面后重试", config.accessDeniedMessage(new InvalidCsrfTokenException(new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", "expected"), "actual")));
}
/** Plain purpose: verify a valid session without an allowed role keeps the generic denial message. Related files: SecurityConfig.java, PagePermissionService.java. Flow: controller role check -> access denial -> safe 403 response. */
@Test void authorizationFailureKeepsGenericMessage() {
SecurityConfig config = new SecurityConfig();
assertEquals("没有权限", config.accessDeniedMessage(new AccessDeniedException("internal detail")));
}
}
let csrfReady = false;
/** 代码作用(白话):读取同站 Cookie 中的 CSRF 值,供会改变数据的请求放入校验头;关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):csrf 接口写 Cookie -> 此方法读取 Cookie -> POST/PUT/DELETE 请求头。 */
function csrfHeader() { const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/); return match ? { 'X-XSRF-TOKEN': decodeURIComponent(match[1]) } : {}; }
/** Plain purpose: detect multipart form data so the browser can create its required boundary header. Related files: device-api-client.js, DeviceAssetController.java. Flow: device form -> FormData -> shared request -> browser multipart header -> controller binding. */
function isFormDataBody(body) { return typeof FormData !== 'undefined' && body instanceof FormData; }
/** Plain purpose: notify the application that the server rejected the current session and it must return to login. Related files: auth-store.js, router/index.js. Flow: protected API 401 -> browser event -> cleared auth state -> login route. */
function notifyExpiredSession() { if (typeof window !== 'undefined') window.dispatchEvent(new Event('xyw-auth-expired')); }
/** 代码作用(白话):首次写操作前请求 CSRF Cookie,登录接口已由后端单独豁免。关联文件:AuthController.java、SecurityConfig.java。关联逻辑(调用链/数据流):前端写请求 -> /api/auth/csrf -> XSRF-TOKEN Cookie -> 安全层校验。 */
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login' || csrfReady) return csrfHeader(); const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); csrfReady = true; return csrfHeader(); }
export async function csrfHeadersFor(path, method) { if (method === 'GET' || path === '/api/auth/login') return csrfHeader(); const existing = csrfHeader(); if (Object.keys(existing).length) return existing; const response = await fetch('/api/auth/csrf', { credentials: 'include' }); if (!response.ok) throw new Error('安全校验初始化失败'); const refreshed = csrfHeader(); if (!Object.keys(refreshed).length) throw new Error('安全校验初始化失败'); return refreshed; }
/** 代码作用(白话):统一发送认证请求并让浏览器自动携带 HttpOnly 会话 Cookie。关联文件:AuthController.java、auth-store.js。关联逻辑(调用链/数据流):登录页/路由守卫 -> fetch -> AuthController -> Cookie/当前用户。 */
export async function request(path, options = {}) { const method = (options.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...csrf, ...(options.headers || {}) }, ...options }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '请求失败'); return payload.data; }
export async function request(path, options = {}) { const { headers: suppliedHeaders = {}, body, ...requestOptions } = options; const method = (requestOptions.method || 'GET').toUpperCase(); const csrf = await csrfHeadersFor(path, method); const headers = { ...csrf, ...suppliedHeaders }; if (!isFormDataBody(body) && !headers['Content-Type']) headers['Content-Type'] = 'application/json'; const response = await fetch(path, { ...requestOptions, body, credentials: 'include', headers }); const payload = await response.json().catch(() => ({})); if (response.status === 401 && path !== '/api/auth/login') notifyExpiredSession(); if (!response.ok || payload.code !== 200) throw new Error(payload.message || '请求失败'); return payload.data; }
/** 代码作用(白话):提交账号密码以建立 HttpOnly 会话。关联文件:LoginView.js、AuthController.java。关联逻辑(调用链/数据流):登录表单 -> POST login -> Set-Cookie -> 当前用户。 */
export function login(credentials) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(credentials) }); }
/** 代码作用(白话):恢复浏览器已有会话并获取菜单权限。关联文件:auth-store.js、AuthController.java。关联逻辑(调用链/数据流):应用启动 -> GET me -> 路由/菜单状态。 */
......
......@@ -2,6 +2,10 @@ import { reactive } from 'vue/dist/vue.esm-bundler.js';
import { getCurrentUser, login, logout } from './auth-api-client.js';
/** 代码作用(白话):保存内存中的安全用户资料,页面刷新后只从后端 Cookie 恢复而不写入浏览器存储。关联文件:router/index.js、App.js。关联逻辑(调用链/数据流):Auth API -> authState -> 路由守卫/菜单。 */
export const authState = reactive({ ready: false, user: null });
/** Plain purpose: remove a browser identity whose server-side session is no longer valid and return to the public login route. Related files: auth-api-client.js, router/index.js. Flow: protected API 401 -> browser event -> authState clear -> login route. */
export function expireSession() { authState.user = null; authState.ready = true; if (typeof window !== 'undefined') window.location.hash = '#/login'; }
/** Plain purpose: receive a shared API-session-expired event without making auth-api-client and auth-store import each other. Related files: auth-api-client.js, auth-store.js. Flow: request helper -> browser event -> expireSession -> login route. */
if (typeof window !== 'undefined') window.addEventListener('xyw-auth-expired', expireSession);
/** 代码作用(白话):向后端恢复一次当前会话,失败时清除内存身份。关联文件:auth-api-client.js。关联逻辑(调用链/数据流):路由守卫 -> /api/auth/me -> authState。 */
export async function bootstrapAuth() { try { authState.user = await getCurrentUser(); } catch (_) { authState.user = null; } finally { authState.ready = true; } return authState.user; }
/** 代码作用(白话):完成登录并保存安全用户资料供当前页面使用。关联文件:LoginView.js。关联逻辑(调用链/数据流):表单 -> login API -> authState -> 跳转。 */
......
/** 文件用途(白话):统一调用设备资产接口,并将非成功响应变为页面可显示的错误。 */
async function request(path, options = {}) {
/** 代码作用(白话):发送一次设备接口请求并提取统一响应数据。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):页面动作 -> request -> ApiResponse -> 页面状态或错误提示。 */
const response = await fetch(path, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '设备资产请求失败');
return payload.data;
}
import { request } from '../auth/auth-api-client.js';
/** File purpose (plain language): sends device requests through the shared cookie and CSRF-safe authentication helper. */
/** 代码作用(白话):按筛选条件读取设备列表。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):筛选器 -> GET -> 分页响应 -> 表格。 */
export function listDeviceAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key,value); }); return request('/api/device-assets?' + params); }
/** 代码作用(白话):用 multipart 表单新建设备。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 文件/设备写入 -> 响应。 */
......
......@@ -5,6 +5,8 @@ export const test = base;
/** Plain purpose: register deterministic authentication responses before a test opens a protected route. Related files: auth-store.js, router/index.js, and all files in frontend/tests. Data flow: test setup -> mocked current-user/CSRF requests -> route guard -> protected page. */
test.beforeEach(async ({ page }) => {
/** Plain purpose: add the browser-visible CSRF Cookie used by the shared request helper in either local Playwright port. Related files: auth-api-client.js, SecurityConfig.java. Flow: test context Cookie -> X-XSRF-TOKEN header -> protected request fixture. */
await page.context().addCookies([{ name: 'XSRF-TOKEN', value: 'playwright-csrf', domain: '127.0.0.1', path: '/' }]);
const user = {
id: 1,
username: 'playwright-asset-admin',
......
......@@ -28,20 +28,34 @@ test('rejects an oversized image before device save', async ({ page }) => {
/** Plain purpose: verify dialog save sends POST for new devices and PUT for edits. Related files: DeviceAssetView.js, device-api-client.js. Flow: dialog save -> fetch POST/PUT -> API response -> list refresh. */
test('creates and edits a device asset through the dialog', async ({ page }) => {
let createRequested=false,updateRequested=false;
let createRequested=false,updateRequested=false,csrfRequested=false,createCsrfHeader='',createContentType='';
/** Plain purpose: simulate a missing browser CSRF Cookie and issue a replacement token before the device write. Related files: auth-api-client.js, SecurityConfig.java. Flow: missing Cookie -> GET csrf -> replacement Cookie -> protected multipart POST. */
await page.context().clearCookies();
await page.route('**/api/auth/csrf', async route=>{csrfRequested=true;await page.context().addCookies([{name:'XSRF-TOKEN',value:'refreshed-csrf',domain:'127.0.0.1',path:'/'}]);await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data:'refreshed-csrf'})});});
/** Plain purpose: return list data and record which save method was sent. Related files: DeviceAssetView.js, device-api-client.js. Flow: form submit -> route interception -> successful JSON -> refreshed table. */
await page.route('**/api/device-assets**', async route=>{const request=route.request();if(request.method()==='POST')createRequested=true;if(request.method()==='PUT')updateRequested=true;const data=request.method()==='GET'?{records:[{id:1,deviceName:'iPhone 15-01',imageAttachment1Url:null,imageAttachment2Url:null,userPersonId:null,userPersonName:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5f85\u786e\u8ba4',createTime:'',updateTime:''}],total:1,page:1,size:20}:{id:1,deviceName:'iPhone 15-01'};await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data})});});
await page.route('**/api/device-assets**', async route=>{const request=route.request();if(request.method()==='POST'){createRequested=true;createCsrfHeader=request.headers()['x-xsrf-token']||'';createContentType=request.headers()['content-type']||'';}if(request.method()==='PUT')updateRequested=true;const data=request.method()==='GET'?{records:[{id:1,deviceName:'iPhone 15-01',imageAttachment1Url:null,imageAttachment2Url:null,userPersonId:null,userPersonName:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5f85\u786e\u8ba4',createTime:'',updateTime:''}],total:1,page:1,size:20}:{id:1,deviceName:'iPhone 15-01'};await route.fulfill({contentType:'application/json',body:JSON.stringify({code:200,message:'success',data})});});
await page.goto('/asset/#/device-assets');
await page.getByRole('button',{name:'\u65b0\u589e\u8bbe\u5907'}).click();
await page.getByRole('dialog').getByRole('textbox').first().fill('\u65b0\u589e\u6d4b\u8bd5\u8bbe\u5907');
await page.getByRole('dialog').getByRole('button',{name:'\u4fdd\u5b58'}).click();
await expect.poll(()=>createRequested).toBe(true);
await expect.poll(()=>csrfRequested).toBe(true);
expect(createCsrfHeader).toBe('refreshed-csrf');
expect(createContentType).toContain('multipart/form-data; boundary=');
await page.locator('.el-table').getByRole('button',{name:'\u7f16\u8f91'}).click();
await page.getByRole('dialog').getByRole('textbox').first().fill('\u7f16\u8f91\u540e\u7684\u8bbe\u5907');
await page.getByRole('dialog').getByRole('button',{name:'\u4fdd\u5b58'}).click();
await expect.poll(()=>updateRequested).toBe(true);
});
/** Plain purpose: verify an invalidated server session clears local identity and returns the browser to login without retrying the failed list request. Related files: auth-api-client.js, auth-store.js. Flow: device GET 401 -> auth-expired event -> auth state clear -> login route. */
test('redirects to login after an invalid device session', async ({ page }) => {
/** Plain purpose: return an expired-session response for the first device page request. Related files: SecurityConfig.java, auth-api-client.js. Flow: GET device list -> 401 envelope -> session-expired event. */
await page.route('**/api/device-assets**', async route=>route.fulfill({status:401,contentType:'application/json',body:JSON.stringify({code:401,message:'\u8bf7\u5148\u767b\u5f55',data:null})}));
await page.goto('/asset/#/device-assets');
await expect(page).toHaveURL(/#\/login$/);
});
/** Plain purpose: verify fixed status options are shown and an unsupported file never becomes an upload value. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: open form -> status dropdown/file choose -> client validation message. */
test('shows fixed statuses and rejects an unsupported image type', async ({ page }) => {
/** Plain purpose: return an empty list to isolate the create dialog. Related files: DeviceAssetView.js, device-api-client.js. Flow: GET -> empty table -> create dialog. */
......
......@@ -2,7 +2,7 @@
The frontend identifies the current user through `GET /api/auth/me`, which reads the current database row. Protected backend writes use the role stored in the signed login token. `SystemUserAdminService.updateUser` currently changes role, status, and page permissions without increasing `auth_version`; therefore an old token can retain a lower role while the UI renders the newly promoted role.
`phone-api-client.js`, `wecom-api-client.js`, and `system-user-api-client.js` use the shared authentication request helper. `device-api-client.js` uses a separate raw `fetch`, so it neither requests the CSRF cookie nor sends the `X-XSRF-TOKEN` header. Spring Security maps both a missing CSRF token and role denial to a generic 403 response, which conceals the cause during diagnosis.
`phone-api-client.js`, `wecom-api-client.js`, and `system-user-api-client.js` use the shared authentication request helper. `device-api-client.js` uses a separate raw `fetch`, so it does not request the CSRF cookie or send the `X-XSRF-TOKEN` header. `DeviceAssetController` also lacks server-side administrator checks, unlike phone and enterprise-WeChat controllers; frontend route metadata cannot stop a direct API call. Spring Security maps both a missing CSRF token and role denial to a generic 403 response, which conceals the cause during diagnosis.
## Goals / Non-Goals
......@@ -11,6 +11,8 @@ The frontend identifies the current user through `GET /api/auth/me`, which reads
- Make a role, status, or page-permission change invalidate the account's existing login token before its new authorization state is shown as usable.
- Preserve the existing fixed roles and server-side authorization checks; a developer remains the highest-privilege role but must reauthenticate after an authorization change.
- Use one authenticated request path for device GET, multipart create/update, and delete operations, including cookies and CSRF headers for every write.
- Enforce the existing administrator-only device rule in the backend for device rows, lookups, and controlled image reads.
- Remove stale browser identity on 401 and reacquire CSRF input if its Cookie is absent.
- Return a safe, actionable distinction between expired authentication, CSRF failure, and ordinary authorization denial.
**Non-Goals:**
......@@ -39,7 +41,17 @@ The security error handler will retain generic authorization wording for ordinar
CSRF(跨站请求伪造防护:要求浏览器把服务端发出的随机值同时放在 Cookie 和请求头中) remains mandatory for all non-login writes. Without it, another website could submit a write using the user's ambient login Cookie.
### 4. Test the real failure paths before declaring the fix
### 4. Make administrator-only device access a backend rule
`PagePermissionService` will expose one reusable administrator requirement that reads the authenticated principal and accepts only `DEVELOPER` and `SUPER_ADMIN`. `DeviceAssetController` will call it before every list, lookup, image-read, create, update, and delete endpoint. This keeps the approved device access model unchanged while making the API enforce it.
Adding a new device entry to the configurable page-permission map was considered but rejected for this change. The approved product behavior is administrator-only; adding `READ`/`EDIT` assignment for ordinary roles would be a product-scope expansion and must be proposed separately.
### 5. Expire the browser view and verify actual CSRF Cookie presence
When the shared request helper receives a 401, it will signal the authentication state to clear the in-memory user and redirect to `/login`; it will not retry a potentially non-idempotent write. The CSRF helper will treat the Cookie as the source of truth: it skips initialization only when `XSRF-TOKEN` is actually present, otherwise it requests `/api/auth/csrf` again.
### 6. Test the real failure paths before declaring the fix
Backend tests will prove that a promoted user token becomes invalid, a fresh developer token can manage users, and a non-developer token remains denied. Browser tests will verify that the first device write initializes CSRF, sends cookies and the token header, preserves multipart boundaries, and shows the distinct safe error messages.
......@@ -49,13 +61,15 @@ Backend tests will prove that a promoted user token becomes invalid, a fresh dev
- [A change increments `auth_version` unnecessarily] -> Compare the persisted role, status, and normalized permission JSON before incrementing; no-op edits retain the current session.
- [Setting JSON content type for multipart] -> Detect `FormData` and let the browser add the boundary; otherwise uploads would reach the server as malformed data.
- [Detailed 403 messages disclose security state] -> Restrict messages to three generic client actions: login again, refresh/retry, or no permission.
- [A logged-in ordinary user bypasses the device route] -> Require an administrator in every device controller endpoint, including the opaque image endpoint.
- [The CSRF Cookie disappears while the JavaScript flag remains true] -> Test Cookie absence and reacquisition rather than trusting an in-memory readiness flag.
- [Concurrent account updates] -> Use the existing single-row update path and increment from the persisted version; the current project has no optimistic-lock column, so concurrent administrator updates remain outside this targeted fix.
## Migration Plan
1. Deploy the backend and frontend together; no data migration or schema change is required.
2. Existing sessions continue until a managed account's authorization state changes. That change invalidates its previous session at the next protected request.
3. Verify a device create, multipart device update, phone write, enterprise-WeChat write, and system-user write using a fresh developer login.
3. Verify a device create, multipart device update, device image read, phone write, enterprise-WeChat write, and system-user write using a fresh developer login; verify an ordinary user cannot call any device endpoint.
4. Roll back by reverting the application changes. Existing `auth_version` values and asset data remain intact; already-invalidated sessions simply need a new login.
## Open Questions
......
## Why
An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/me` while its already-issued login token still contains the prior role. The UI then identifies the user as an administrator but protected writes, such as `POST /api/system-users`, return 403. Separately, device-asset writes bypass the existing CSRF protection flow and are rejected regardless of the user's role.
An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/me` while its already-issued login token still contains the prior role. The UI then identifies the user as an administrator but protected writes, such as `POST /api/system-users`, return 403. Separately, device-asset writes bypass the existing CSRF protection flow, and the device controller relies on a frontend-only administrator route so any authenticated user can call its CRUD endpoints directly.
## What Changes
- Invalidate an existing login session whenever a managed account's role, enabled status, or page permissions change. The next protected request must require a fresh login so the token role and database role cannot diverge.
- Keep the current rule that `DEVELOPER` and `SUPER_ADMIN` are administrators; do not relax role checks or allow creation of extra developer accounts.
- Route every device-asset write request through the shared authenticated request helper so it includes cookies and the CSRF header, including multipart `POST` and `PUT` requests.
- Enforce the approved administrator-only device access rule in the backend for list, lookup, image read, create, update, and delete endpoints; do not rely on hidden menus or route metadata as authorization.
- Clear local browser identity and redirect to login after a 401 session-invalid response. Reacquire CSRF input whenever its Cookie is absent instead of trusting an in-memory initialization flag.
- Make 403 verification distinguish an authorization failure from a missing/invalid CSRF token in automated tests and developer diagnostics, without exposing sensitive account information.
## Capabilities
......@@ -22,6 +24,6 @@ An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/
## Impact
- Backend: `SystemUserAdminService`, authentication-token filtering, and focused authorization tests. No database schema change or migration is required because `auth_version` already exists.
- Frontend: the shared auth request utility, device API client, and browser tests. The page layout, role names, and asset data contract remain unchanged.
- Backend: `SystemUserAdminService`, `PagePermissionService`, `DeviceAssetController`, authentication-token filtering, and focused authorization tests. No database schema change or migration is required because `auth_version` already exists.
- Frontend: the shared auth request utility and state, device API client, and browser tests. The page layout, role names, and asset data contract remain unchanged.
- Security: CSRF(跨站请求伪造防护:阻止第三方网站借用登录 Cookie 发起写操作) continues to protect every non-login write. Not preserving it would make the 403 disappear at the cost of allowing forged writes.
......@@ -21,3 +21,18 @@ The device list, company-person lookup, and controlled opaque image access SHALL
#### Scenario: Read device list after security fix
- **WHEN** an authenticated authorized user loads the device asset page
- **THEN** the client receives the existing paged list shape and renders it without a write-oriented CSRF requirement
### Requirement: Device APIs enforce administrator access on the server
The system SHALL require an authenticated `DEVELOPER` or `SUPER_ADMIN` before executing device list, company-person lookup, controlled image read, create, update, or delete operations. A non-administrator MUST receive authorization denial even when directly calling the API without using the sidebar or route.
#### Scenario: Ordinary account bypasses the device page route
- **WHEN** an authenticated non-administrator directly sends `POST`, `PUT`, or `DELETE` to `/api/device-assets`
- **THEN** the system returns authorization denial and does not write device rows or files
#### Scenario: Ordinary account reads a device attachment URL
- **WHEN** an authenticated non-administrator requests `/api/device-assets/files/{identifier}`
- **THEN** the system returns authorization denial and does not stream the image
#### Scenario: Administrator uses any device endpoint
- **WHEN** an authenticated `DEVELOPER` or `SUPER_ADMIN` calls a device list, lookup, image, or CRUD endpoint with otherwise valid input
- **THEN** the server permits the request to continue to the existing device service behavior
......@@ -25,3 +25,14 @@ The system SHALL preserve generic authorization denial for a valid authenticated
#### Scenario: Browser omits CSRF input
- **WHEN** an authenticated browser sends a protected non-login write without a valid CSRF token
- **THEN** the system returns a safe refresh-and-retry response and does not execute the controller business method
### Requirement: Browser session state follows authentication failure
The shared browser request helper SHALL clear the in-memory authenticated user and navigate to the login route when a protected request returns 401. It MUST NOT automatically repeat the failed request. It SHALL request CSRF input again whenever the `XSRF-TOKEN` Cookie is absent for a protected non-login write.
#### Scenario: Authorization change invalidates the active browser session
- **WHEN** an already-open browser sends a protected request with a token invalidated by an authorization-state change
- **THEN** the browser clears its local identity, navigates to login, and does not retry the failed write
#### Scenario: CSRF Cookie is missing after prior initialization
- **WHEN** a protected browser write begins after the `XSRF-TOKEN` Cookie has been removed or expired
- **THEN** the client requests fresh CSRF input before sending the write
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment