feat(登录安全): 实现 RSA 密码加密传输

后端:
- 新增 RsaEncryptionUtil 工具类,支持 RSA 2048 位加解密
- 新增 RsaKeyRotationTask 定时任务,每月 1 日凌晨 2 点自动更换密钥
- 新增 EncryptedLoginRequest 和 PublicKeyResponse DTO
- AuthController 添加 /public-key 和 /login/encrypted 接口

前端:
- 添加 jsencrypt 依赖用于 RSA 加密
- 新增 encryption.ts 工具函数
- auth.ts 添加 getPublicKey 和 loginEncrypted API
- user.ts 修改 login 函数使用 RSA 加密流程

feat(操作日志): 添加请求参数和请求接口字段

- 数据库迁移 V50 添加 request_uri 字段
- LogAspect 记录请求 URI
- OperationLogResponse 新增 requestParams 和 requestUri 字段
- 前端 OperationLogView 详情弹窗展示新字段

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-03-24 18:06:19 +08:00
parent 1038a70d92
commit c935988188
18 changed files with 434 additions and 3 deletions

View File

@ -61,3 +61,63 @@
| `AdminTenantController.java` | 修改详情查询方法 | | `AdminTenantController.java` | 修改详情查询方法 |
| `admin.ts` | 扩展类型定义 | | `admin.ts` | 扩展类型定义 |
| `TenantListView.vue` | 更新详情展示 | | `TenantListView.vue` | 更新详情展示 |
---
## 工作内容 - 学校端操作日志请求参数与接口展示
### 背景
在学校端操作日志页面,需要记录并返回以下三个信息:
1. **操作人** - 谁执行的操作(已有 `userId`、`userRole`
2. **请求参数** - 操作时传入的参数(`requestParams`
3. **请求接口** - 执行的具体 API 接口路径(`requestUri`
当前后端已通过 `LogAspect` 自动记录这些信息到数据库,但未返回给前端展示。
### 修改内容
#### 1. 数据库迁移
**文件**: `V50__add_request_uri_to_operation_log.sql`
- 添加 `request_uri` 字段 - VARCHAR(500),记录请求接口路径
#### 2. 后端实体修改
**文件**: `OperationLog.java`
- 新增 `requestUri` 字段
#### 3. 后端切面修改
**文件**: `LogAspect.java`
- 在 `before()` 方法中记录 `requestURI`
#### 4. 后端 Response DTO 修改
**文件**: `OperationLogResponse.java`
- 新增 `requestParams` 字段 - 请求参数 JSON
- 新增 `requestUri` 字段 - 请求接口路径
#### 5. 后端 Controller 修改
**文件**: `SchoolOperationLogController.java`
- 在 `convertToResponse()` 方法中添加字段映射
#### 6. 前端类型定义更新
**文件**: `school.ts`
- `OperationLog` 接口添加 `requestParams``requestUri` 字段
#### 7. 前端页面修改
**文件**: `OperationLogView.vue`
- 详情弹窗新增"请求接口"展示项
- 详情弹窗新增"请求参数"展示项JSON 格式化)
### 验证步骤
1. ✅ 后端编译成功
2. ⏳ 数据库迁移待执行
3. ⏳ 启动服务测试
### 文件清单
| 文件 | 修改内容 |
|------|---------|
| `V50__add_request_uri_to_operation_log.sql` | 新增数据库迁移 |
| `OperationLog.java` | 新增 `requestUri` 字段 |
| `LogAspect.java` | 记录 `requestURI` |
| `OperationLogResponse.java` | 新增返回字段 |
| `SchoolOperationLogController.java` | 字段映射 |
| `school.ts` | 类型定义更新 |
| `OperationLogView.vue` | 详情展示新增字段 |

View File

@ -18,6 +18,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",
@ -4463,6 +4464,11 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsencrypt": {
"version": "3.5.4",
"resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.5.4.tgz",
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA=="
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",

View File

@ -26,6 +26,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"jsencrypt": "^3.3.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-vue-next": "^0.575.0", "lucide-vue-next": "^0.575.0",
"pdfjs-dist": "^3.11.174", "pdfjs-dist": "^3.11.174",

View File

@ -1,4 +1,5 @@
import { http } from './index'; import { http } from './index';
import type { EncryptedLoginParams, PublicKeyResponse } from '@/utils/encryption';
export interface LoginParams { export interface LoginParams {
account: string; account: string;
@ -78,3 +79,25 @@ export function changePassword(oldPassword: string, newPassword: string): Promis
params: { oldPassword, newPassword }, params: { oldPassword, newPassword },
}); });
} }
// ========== RSA 加密登录相关 API ==========
/**
* RSA
*/
export function getPublicKey(): Promise<PublicKeyResponse> {
return http.get('/v1/auth/public-key');
}
/**
* RSA
* @param params
*/
export function loginEncrypted(params: EncryptedLoginParams): Promise<LoginResponse> {
return http.post('/v1/auth/login/encrypted', {
username: params.username,
encryptedPassword: params.encryptedPassword,
role: params.role,
keyVersion: params.keyVersion,
});
}

View File

@ -860,6 +860,8 @@ export interface OperationLog {
oldValue: string | null; oldValue: string | null;
newValue: string | null; newValue: string | null;
ipAddress: string | null; ipAddress: string | null;
requestParams?: string; // 请求参数 JSON
requestUri?: string; // 请求接口路径
createdAt: string; createdAt: string;
} }

View File

@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { router } from '@/router'; import { router } from '@/router';
import * as authApi from '@/api/auth'; import * as authApi from '@/api/auth';
import { rsaEncrypt } from '@/utils/encryption';
export interface User { export interface User {
id: number; id: number;
@ -26,10 +27,25 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => !!token.value); const isLoggedIn = computed(() => !!token.value);
const userRole = computed(() => user.value?.role || null); const userRole = computed(() => user.value?.role || null);
// 登录 // 登录(使用 RSA 加密)
async function login(account: string, password: string, role: string) { async function login(account: string, password: string, role: string) {
try { try {
const data = await authApi.login({ account, password, role }); // 1. 获取 RSA 公钥
const { publicKey, keyVersion } = await authApi.getPublicKey();
// 2. 使用 RSA 公钥加密密码
const encryptedPassword = rsaEncrypt(password, publicKey);
if (!encryptedPassword) {
throw new Error('密码加密失败');
}
// 3. 使用加密后的密码登录
const data = await authApi.loginEncrypted({
username: account,
encryptedPassword,
role,
keyVersion,
});
// 后端返回格式: { token, userId, username, name, role, tenantId } // 后端返回格式: { token, userId, username, name, role, tenantId }
token.value = data.token; token.value = data.token;

View File

@ -0,0 +1,37 @@
import { JSEncrypt } from 'jsencrypt';
/**
* RSA
* 使 jsencrypt RSA
*/
/**
* 使 RSA
* @param data
* @param publicKey RSA Base64 PEM
* @returns Base64
*/
export function rsaEncrypt(data: string, publicKey: string): string {
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
const result = encrypt.encrypt(data);
return result || '';
}
/**
* RSA
*/
export interface PublicKeyResponse {
publicKey: string;
keyVersion: number;
}
/**
*
*/
export interface EncryptedLoginParams {
username: string;
encryptedPassword: string;
role: string;
keyVersion?: number;
}

View File

@ -118,6 +118,12 @@
<a-descriptions-item label="操作描述" :span="2"> <a-descriptions-item label="操作描述" :span="2">
{{ selectedLog.description }} {{ selectedLog.description }}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="请求接口" :span="2">
{{ selectedLog.requestUri || '-' }}
</a-descriptions-item>
<a-descriptions-item label="请求参数" :span="2">
<pre class="json-data">{{ formatJson(selectedLog.requestParams) }}</pre>
</a-descriptions-item>
<a-descriptions-item label="变更前数据" :span="2"> <a-descriptions-item label="变更前数据" :span="2">
<pre class="json-data">{{ formatJson(selectedLog.oldValue) }}</pre> <pre class="json-data">{{ formatJson(selectedLog.oldValue) }}</pre>
</a-descriptions-item> </a-descriptions-item>

View File

@ -111,6 +111,7 @@ public class LogAspect {
// 记录请求信息 // 记录请求信息
operationLog.setIpAddress(getIpAddress(request)); operationLog.setIpAddress(getIpAddress(request));
operationLog.setUserAgent(request.getHeader("User-Agent")); operationLog.setUserAgent(request.getHeader("User-Agent"));
operationLog.setRequestUri(request.getRequestURI());
// 记录请求参数 // 记录请求参数
if (logAnnotation.recordParams()) { if (logAnnotation.recordParams()) {

View File

@ -0,0 +1,36 @@
package com.reading.platform.common.task;
import com.reading.platform.common.util.RsaEncryptionUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* RSA 密钥定时轮换任务
* 每月自动更换 RSA 密钥对增强安全性
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RsaKeyRotationTask {
private final RsaEncryptionUtil rsaEncryptionUtil;
/**
* 每月 1 日凌晨 2 点自动更换 RSA 密钥
* cron 表达式:
*/
@Scheduled(cron = "0 0 2 1 * ?")
public void rotateRsaKey() {
try {
log.info("开始执行 RSA 密钥定时更换任务");
long oldVersion = rsaEncryptionUtil.getKeyVersion();
rsaEncryptionUtil.generateNewKeyPair();
long newVersion = rsaEncryptionUtil.getKeyVersion();
log.info("RSA 密钥定时更换任务执行完成,版本: {} -> {}", oldVersion, newVersion);
} catch (Exception e) {
log.error("RSA 密钥定时更换任务执行失败", e);
}
}
}

View File

@ -0,0 +1,132 @@
package com.reading.platform.common.util;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicLong;
/**
* RSA 加密工具类
* 支持密钥对生成加密解密密钥版本管理
*/
@Slf4j
@Component
public class RsaEncryptionUtil {
private static final String ALGORITHM = "RSA";
private static final int KEY_SIZE = 2048;
private volatile KeyPair keyPair;
private volatile String currentPublicKey;
private final AtomicLong keyVersion = new AtomicLong(0);
/**
* 初始化时生成密钥对
*/
@PostConstruct
public void init() throws Exception {
generateNewKeyPair();
log.info("RSA 加密工具初始化完成,当前密钥版本: {}", keyVersion.get());
}
/**
* 生成新的 RSA 密钥对
* 每月定时执行也可手动调用
*/
public synchronized void generateNewKeyPair() throws Exception {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance(ALGORITHM);
generator.initialize(KEY_SIZE);
this.keyPair = generator.generateKeyPair();
this.currentPublicKey = Base64.getEncoder().encodeToString(
keyPair.getPublic().getEncoded()
);
keyVersion.incrementAndGet();
log.info("RSA 密钥对已更换,新版本号: {},密钥长度: {} 位", keyVersion.get(), KEY_SIZE);
} catch (Exception e) {
log.error("生成 RSA 密钥对失败", e);
throw e;
}
}
/**
* 获取当前公钥Base64 编码
*/
public String getPublicKey() {
return currentPublicKey;
}
/**
* 获取当前密钥版本号
*/
public long getKeyVersion() {
return keyVersion.get();
}
/**
* 使用私钥解密数据
*
* @param encryptedData Base64 编码的加密数据
* @return 解密后的明文
*/
public String decrypt(String encryptedData) throws Exception {
if (encryptedData == null || encryptedData.isEmpty()) {
throw new IllegalArgumentException("加密数据不能为空");
}
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
byte[] decrypted = cipher.doFinal(
Base64.getDecoder().decode(encryptedData)
);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("RSA 解密失败", e);
throw new Exception("密码解密失败,请重试", e);
}
}
/**
* 使用公钥加密数据主要用于测试
*
* @param plainText 明文
* @param publicKeyBase64 Base64 编码的公钥
* @return Base64 编码的密文
*/
public String encrypt(String plainText, String publicKeyBase64) throws Exception {
if (plainText == null || plainText.isEmpty()) {
throw new IllegalArgumentException("明文不能为空");
}
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
java.security.spec.X509EncodedKeySpec spec = new java.security.spec.X509EncodedKeySpec(keyBytes);
java.security.KeyFactory keyFactory = java.security.KeyFactory.getInstance(ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(spec);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("RSA 加密失败", e);
throw new Exception("密码加密失败", e);
}
}
/**
* 使用当前公钥加密数据用于测试
*/
public String encryptWithCurrentKey(String plainText) throws Exception {
return encrypt(plainText, currentPublicKey);
}
}

View File

@ -8,9 +8,12 @@ import com.reading.platform.common.mapper.ParentMapper;
import com.reading.platform.common.mapper.TenantMapper; import com.reading.platform.common.mapper.TenantMapper;
import com.reading.platform.common.mapper.TeacherMapper; import com.reading.platform.common.mapper.TeacherMapper;
import com.reading.platform.common.response.Result; import com.reading.platform.common.response.Result;
import com.reading.platform.common.util.RsaEncryptionUtil;
import com.reading.platform.dto.request.EncryptedLoginRequest;
import com.reading.platform.dto.request.LoginRequest; import com.reading.platform.dto.request.LoginRequest;
import com.reading.platform.dto.request.UpdateProfileRequest; import com.reading.platform.dto.request.UpdateProfileRequest;
import com.reading.platform.dto.response.LoginResponse; import com.reading.platform.dto.response.LoginResponse;
import com.reading.platform.dto.response.PublicKeyResponse;
import com.reading.platform.dto.response.TokenResponse; import com.reading.platform.dto.response.TokenResponse;
import com.reading.platform.dto.response.UpdateProfileResponse; import com.reading.platform.dto.response.UpdateProfileResponse;
import com.reading.platform.dto.response.UserInfoResponse; import com.reading.platform.dto.response.UserInfoResponse;
@ -38,6 +41,37 @@ public class AuthController {
private final TeacherMapper teacherMapper; private final TeacherMapper teacherMapper;
private final ParentMapper parentMapper; private final ParentMapper parentMapper;
private final AdminUserMapper adminUserMapper; private final AdminUserMapper adminUserMapper;
private final RsaEncryptionUtil rsaEncryptionUtil;
@Operation(summary = "获取 RSA 公钥")
@GetMapping("/public-key")
public Result<PublicKeyResponse> getPublicKey() {
PublicKeyResponse response = new PublicKeyResponse();
response.setPublicKey(rsaEncryptionUtil.getPublicKey());
response.setKeyVersion(rsaEncryptionUtil.getKeyVersion());
return Result.success(response);
}
@Operation(summary = "加密登录RSA")
@Log(module = LogModule.AUTH, type = LogOperationType.OTHER, description = "用户加密登录")
@PostMapping("/login/encrypted")
public Result<LoginResponse> loginEncrypted(@Valid @RequestBody EncryptedLoginRequest request) {
// 解密密码
String decryptedPassword;
try {
decryptedPassword = rsaEncryptionUtil.decrypt(request.getEncryptedPassword());
} catch (Exception e) {
return Result.error("密码解密失败,请重试");
}
// 构造普通登录请求
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername(request.getUsername());
loginRequest.setPassword(decryptedPassword);
loginRequest.setRole(request.getRole());
return Result.success(authService.login(loginRequest));
}
@Operation(summary = "用户登录") @Operation(summary = "用户登录")
@Log(module = LogModule.AUTH, type = LogOperationType.OTHER, description = "用户登录") @Log(module = LogModule.AUTH, type = LogOperationType.OTHER, description = "用户登录")

View File

@ -108,6 +108,8 @@ public class SchoolOperationLogController {
.details(log.getDetails()) .details(log.getDetails())
.ipAddress(log.getIpAddress()) .ipAddress(log.getIpAddress())
.userAgent(log.getUserAgent()) .userAgent(log.getUserAgent())
.requestParams(log.getRequestParams())
.requestUri(log.getRequestUri())
.createdAt(log.getCreatedAt()) .createdAt(log.getCreatedAt())
.build(); .build();
} }

View File

@ -0,0 +1,29 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 加密登录请求 DTO
* 用于 RSA 加密传输的登录请求
*/
@Data
@Schema(description = "加密登录请求")
public class EncryptedLoginRequest {
@NotBlank(message = "账号不能为空")
@Schema(description = "登录账号(明文)")
private String username;
@NotBlank(message = "密码不能为空")
@Schema(description = "RSA 加密后的密码Base64 编码)")
private String encryptedPassword;
@NotBlank(message = "角色不能为空")
@Schema(description = "登录角色")
private String role;
@Schema(description = "密钥版本号(可选,用于兼容性)")
private Long keyVersion;
}

View File

@ -48,6 +48,12 @@ public class OperationLogResponse {
@Schema(description = "用户代理") @Schema(description = "用户代理")
private String userAgent; private String userAgent;
@Schema(description = "请求参数JSON 格式)")
private String requestParams;
@Schema(description = "请求接口路径")
private String requestUri;
@Schema(description = "创建时间") @Schema(description = "创建时间")
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }

View File

@ -0,0 +1,18 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* RSA 公钥响应
*/
@Data
@Schema(description = "RSA 公钥响应")
public class PublicKeyResponse {
@Schema(description = "RSA 公钥Base64 编码)")
private String publicKey;
@Schema(description = "密钥版本号")
private Long keyVersion;
}

View File

@ -44,7 +44,10 @@ public class OperationLog extends BaseEntity {
@Schema(description = "用户代理") @Schema(description = "用户代理")
private String userAgent; private String userAgent;
@Schema(description = "请求参数JSON 格式,不返回给前端") @Schema(description = "请求参数JSON 格式")
private String requestParams; private String requestParams;
@Schema(description = "请求接口路径")
private String requestUri;
} }

View File

@ -0,0 +1,19 @@
-- =====================================================
-- 操作日志表添加请求接口字段
-- 版本V49
-- 创建时间2026-03-24
-- 描述:在 operation_log 表中添加 request_uri 字段,用于记录请求接口路径
-- =====================================================
-- -----------------------------------------------------
-- 1. 添加 request_uri 字段
-- -----------------------------------------------------
ALTER TABLE `operation_log`
ADD COLUMN `request_uri` VARCHAR(500) COMMENT '请求接口路径' AFTER `request_params`;
-- -----------------------------------------------------
-- 说明:
-- - request_uri 字段用于存储请求的接口路径(如:/api/v1/school/teachers
-- - 该字段与 request_params 配合使用,完整记录操作请求信息
-- - 由 LogAspect 自动记录,通过 @Log 注解标记的操作会自动保存
-- -----------------------------------------------------