/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** File purpose (plain language): verifies that account authorization changes revoke old login versions while existing role boundaries remain enforced. */
classSystemUserAdminServiceTest{
privateSystemUserMapperusers;
privateSystemUserAdminServiceservice;
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** File purpose (plain language): verifies that security rejections provide safe browser actions without leaking account or token details. */
classSecurityConfigTest{
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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})=>{
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. */
@@ -28,20 +28,34 @@ test('rejects an oversized image before device save', async ({ page }) => {
...
@@ -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. */
/** 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})=>{
test('creates and edits a device asset through the dialog',async({page})=>{
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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. */
/** 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})=>{
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. */
/** 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. */
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.
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
## Goals / Non-Goals
...
@@ -11,6 +11,8 @@ The frontend identifies the current user through `GET /api/auth/me`, which reads
...
@@ -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.
- 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.
- 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.
- 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.
- Return a safe, actionable distinction between expired authentication, CSRF failure, and ordinary authorization denial.
**Non-Goals:**
**Non-Goals:**
...
@@ -39,7 +41,17 @@ The security error handler will retain generic authorization wording for ordinar
...
@@ -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.
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.
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
...
@@ -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.
-[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.
-[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.
-[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.
-[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
## Migration Plan
1. Deploy the backend and frontend together; no data migration or schema change is required.
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.
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.
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.
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
## 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.
- 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.
- 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.
- 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.
- 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
## Capabilities
...
@@ -22,6 +24,6 @@ An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/
...
@@ -22,6 +24,6 @@ An account promoted to `DEVELOPER` can receive full permissions from `/api/auth/
## Impact
## Impact
- Backend: `SystemUserAdminService`, authentication-token filtering, and focused authorization tests. No database schema change or migration is required because `auth_version` already exists.
- 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, device API client, and browser tests. The page layout, role names, and asset data contract remain unchanged.
- 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.
- 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
...
@@ -21,3 +21,18 @@ The device list, company-person lookup, and controlled opaque image access SHALL
#### Scenario: Read device list after security fix
#### Scenario: Read device list after security fix
-**WHEN** an authenticated authorized user loads the device asset page
-**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
-**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
...
@@ -25,3 +25,14 @@ The system SHALL preserve generic authorization denial for a valid authenticated
#### Scenario: Browser omits CSRF input
#### Scenario: Browser omits CSRF input
-**WHEN** an authenticated browser sends a protected non-login write without a valid CSRF token
-**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
-**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