Commit f396b4ea by DaiJiezhang

feat: add system user authentication permissions

parent 1625ba48
......@@ -22,7 +22,7 @@ public class SystemUserAdminService {
/** 代码作用(白话):查询管理者可见的非开发者账号,任何响应都不返回哈希;关联文件:SystemUserAdminController.java、UserPermissionView.js。关联逻辑(调用链/数据流):账号页面 -> GET users -> as_system_user(role != DEVELOPER) -> 列表。 */
public List<SystemUserResponse> listVisibleUsers() { assertAdministrator(); return users.selectList(new LambdaQueryWrapper<SystemUserEntity>().ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L).orderByDesc(SystemUserEntity::getId)).stream().map(this::responseOf).toList(); }
/** 代码作用(白话):按当前创建者的角色边界新增非开发者账号;关联文件: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); }
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()); 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); 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. */
......@@ -32,7 +32,7 @@ public class SystemUserAdminService {
/** 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); }
public void resetPassword(Long id, PasswordResetRequest request) { if (!"DEVELOPER".equals(currentRole())) throw new AccessDeniedException("只有开发者可以修改密码"); SystemUserEntity user = findManageableUser(id); requireValidPassword(request.password()); user.setPasswordHash(encoder.encode(request.password())); user.setUpdateTime(LocalDateTime.now()); users.updateById(user); }
/** 代码作用(白话):确认当前会话属于开发者或超级管理员;关联文件:AuthTokenFilter.java、SystemUserAdminController.java。关联逻辑(调用链/数据流):Cookie 身份 -> SecurityContext -> 管理接口准入。 */
private void assertAdministrator() { if (!permissions.isAdministrator(currentRole())) throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):读取过滤器已经核验过的当前角色;关联文件:AuthPrincipal.java、AuthTokenFilter.java。关联逻辑(调用链/数据流):JWT -> SecurityContext principal -> 创建/修改边界。 */
......@@ -41,8 +41,8 @@ public class SystemUserAdminService {
private void assertCreatableRole(String actor, String roleCode) { if ("DEVELOPER".equals(roleCode)) throw new AccessDeniedException("开发者账号仅允许固定账号 Jeddy"); if (!("SUPER_ADMIN".equals(roleCode) || "FINANCE".equals(roleCode) || "HR".equals(roleCode) || "OPERATIONS".equals(roleCode))) throw new IllegalArgumentException("角色无效"); if ("DEVELOPER".equals(actor) || "SUPER_ADMIN".equals(actor)) return; throw new AccessDeniedException("没有账号管理权限"); }
/** 代码作用(白话):按固定查询条件取得可管理账号,避免 Jeddy 通过 URL 或接口细节泄露;关联文件:SystemUserMapper.java、SystemUserResponse.java。关联逻辑(调用链/数据流):账号 id -> 非 DEVELOPER 查询 -> 编辑/重置。 */
private SystemUserEntity findManageableUser(Long id) { SystemUserEntity user = users.selectOne(new LambdaQueryWrapper<SystemUserEntity>().eq(SystemUserEntity::getId, id).ne(SystemUserEntity::getRoleCode, "DEVELOPER").eq(SystemUserEntity::getDeleteTime, 0L)); if (user == null) throw new IllegalArgumentException("账号不存在"); return user; }
/** 代码作用(白话):验证密码长度和用户名相似度,避免弱密码直接进入数据库;关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 规则校验 -> BCrypt 哈希。 */
private void requireValidPassword(String password, String username) { if (password == null || password.length() < 12 || password.length() > 72 || password.toLowerCase().contains(username.toLowerCase())) throw new IllegalArgumentException("密码需为 12-72 位且不能包含用户名"); }
/** 代码作用(白话):验证用户确认的密码长度;用户名可以出现在密码中,规则通过后才生成 BCrypt 哈希。关联文件:PasswordResetRequest.java、SystemUserCreateRequest.java。关联逻辑(调用链/数据流):密码输入 -> 12-72 位校验 -> BCrypt 哈希。 */
private void requireValidPassword(String password) { if (password == null || password.length() < 12 || password.length() > 72) throw new IllegalArgumentException("密码需为 12-72 位"); }
/** 代码作用(白话):将实体转为安全响应,并统一展开固定管理员的有效权限;关联文件:SystemUserResponse.java、PagePermissionService.java。关联逻辑(调用链/数据流):实体 -> 权限计算 -> 前端列表。 */
private SystemUserResponse responseOf(SystemUserEntity user) { return new SystemUserResponse(user.getId(), user.getUsername(), user.getRoleCode(), user.getStatus(), permissions.effectivePermissions(user), user.getPasswordUpdatedAt()); }
}
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.security.web.csrf.CsrfTokenRequestAttributeHandler;
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,accessDeniedMessage(error)))).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()).csrfTokenRequestHandler(csrfRequestHandler()).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(); }
/** 代码作用(白话):指定单页应用使用的 CSRF 请求值读取方式,让前端从 XSRF-TOKEN Cookie 读取的原始值可由 X-XSRF-TOKEN 请求头提交。关联文件:auth-api-client.js、SecurityConfigTest.java。关联逻辑(调用链/数据流):CSRF Cookie -> 前端请求头 -> 此处理器 -> Spring Security 放行合法写请求。 */
CsrfTokenRequestAttributeHandler csrfRequestHandler() { return new CsrfTokenRequestAttributeHandler(); }
/** 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。 */
......
......@@ -80,6 +80,16 @@ class SystemUserAdminServiceTest {
verify(users).insert(any(SystemUserEntity.class));
}
/** Plain purpose: allow the user-approved username-containing password policy while retaining the existing password length boundary. Related files: SystemUserAdminService.java, SystemUserCreateRequest.java. Flow: developer create request -> password validation -> BCrypt encoder -> account insert. */
@Test void developerCanCreateAccountWhenPasswordEqualsUsernameAndContainsJeddy() {
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("Jeddy2026User", "OPERATIONS", "Jeddy2026User", 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");
......
......@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.csrf.InvalidCsrfTokenException;
......@@ -21,4 +22,14 @@ class SecurityConfigTest {
SecurityConfig config = new SecurityConfig();
assertEquals("没有权限", config.accessDeniedMessage(new AccessDeniedException("internal detail")));
}
/** 代码作用(白话):验证单页应用把可读的 CSRF Cookie 值写进请求头后,安全配置会按原始值校验,而不会把合法退出请求误判为伪造请求。关联文件:SecurityConfig.java、auth-api-client.js。关联逻辑(调用链/数据流):XSRF-TOKEN Cookie -> X-XSRF-TOKEN 请求头 -> Spring Security CSRF 校验 -> /api/auth/logout。 */
@Test void csrfRequestHandlerAcceptsRawCookieTokenFromSpaHeader() {
String tokenValue = "csrf-cookie-token";
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("X-XSRF-TOKEN", tokenValue);
DefaultCsrfToken csrfToken = new DefaultCsrfToken("X-XSRF-TOKEN", "_csrf", tokenValue);
assertEquals(tokenValue, new SecurityConfig().csrfRequestHandler().resolveCsrfTokenValue(request, csrfToken));
}
}
......@@ -21,4 +21,4 @@ export default { setup() { const users = ref([]); const loading = ref(false); co
function openPassword(user) { target.value = user; password.value = ''; passwordOpen.value = true; }
/** 代码作用(白话):把新密码提交给后端并立即从页面内存清除;关联文件:system-user-api-client.js、V1__system_user_auth_permissions.sql。关联逻辑(调用链/数据流):新密码 -> BCrypt -> 数据库触发器 -> 旧会话失效。 */
async function savePassword() { try { await resetSystemUserPassword(target.value.id, password.value); password.value = ''; passwordOpen.value = false; ElMessage.success('密码已更新,旧会话已失效'); await load(); } catch (error) { ElMessage.error(error.message || '密码更新失败'); } }
load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位,且不能包含用户名。</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` };
load(); return { users, loading, dialogOpen, passwordOpen, form, pages, roles, editingId, isDeveloper, isAdministratorRole, target, password, load, openCreate, openEdit, save, openPassword, savePassword }; }, template: `<section class="user-permission-page"><header class="page-header"><div><p class="eyebrow">设置 / 账号与权限</p><h2>账号与权限</h2><p>开发者账号 Jeddy 不会在此页面显示;逐页权限由此面板独立配置。</p></div><el-button type="primary" @click="openCreate">新增账号</el-button></header><section class="reference-card"><el-table :data="users" v-loading="loading"><el-table-column prop="username" label="用户名" /><el-table-column prop="roleCode" label="角色" /><el-table-column prop="status" label="状态" /><el-table-column label="操作" width="210"><template #default="scope"><el-button link @click="openEdit(scope.row)">编辑权限</el-button><el-button v-if="isDeveloper" link type="primary" @click="openPassword(scope.row)">设置密码</el-button></template></el-table-column></el-table></section><el-dialog v-model="dialogOpen" :title="editingId ? '编辑账号与权限' : '新增账号'" width="680px"><el-form label-width="112px"><el-form-item label="用户名" v-if="!editingId"><el-input v-model="form.username" /></el-form-item><el-form-item label="角色"><el-select v-model="form.roleCode"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item><el-form-item label="账号状态" v-if="editingId"><el-select v-model="form.status"><el-option label="启用" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item><el-form-item label="初始密码" v-if="!editingId && isDeveloper"><el-input v-model="form.password" type="password" show-password /><small>12-72 位</small></el-form-item><el-alert v-if="!editingId && !isDeveloper" title="超级管理员创建的账号会保持禁用且不设密码,需由开发者设置密码后再启用。" type="info" :closable="false" /><el-divider>页面权限</el-divider><div v-if="isAdministratorRole" class="permission-hint">超级管理员固定拥有全部页面的编辑权限。</div><el-form-item v-for="page in pages" :key="page.key" :label="page.label"><el-radio-group v-model="form.pagePermissions[page.key]" :disabled="isAdministratorRole"><el-radio value="NONE">无权限</el-radio><el-radio value="READ">只读</el-radio><el-radio value="EDIT">编辑</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogOpen=false">取消</el-button><el-button type="primary" @click="save">保存</el-button></template></el-dialog><el-dialog v-model="passwordOpen" title="设置账号密码" width="420px"><p>仅开发者可以设置密码:{{ target?.username }}</p><el-input v-model="password" type="password" show-password placeholder="12-72 位且不含用户名" /><template #footer><el-button @click="passwordOpen=false">取消</el-button><el-button type="primary" @click="savePassword">确认设置</el-button></template></el-dialog></section>` };
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