fix(web-ai): store encryption key in database
This commit is contained in:
-9
@@ -11,7 +11,6 @@ public class AihrWebAiProperties {
|
||||
private String provider = "tavily";
|
||||
private String endpoint = "https://api.tavily.com/search";
|
||||
private String apiKey = "";
|
||||
private String secretKey = "";
|
||||
private int maxResults = 5;
|
||||
|
||||
public boolean isEnabled() {
|
||||
@@ -46,14 +45,6 @@ public class AihrWebAiProperties {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public int getMaxResults() {
|
||||
return maxResults;
|
||||
}
|
||||
|
||||
+33
-5
@@ -2,6 +2,7 @@ package org.dromara.aihr.webai;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
@@ -17,9 +18,11 @@ import java.util.Base64;
|
||||
class AihrWebSecretCodec {
|
||||
|
||||
private static final String PREFIX = "enc:v1:";
|
||||
private static final String CONFIG_KEY = "provider-encryption-v1";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final AihrWebAiProperties properties;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private volatile SecretKeySpec databaseKey;
|
||||
|
||||
String encrypt(String plaintext) {
|
||||
if (plaintext == null || plaintext.isBlank()) {
|
||||
@@ -69,11 +72,36 @@ class AihrWebSecretCodec {
|
||||
}
|
||||
|
||||
private SecretKeySpec key() throws Exception {
|
||||
String secret = properties.getSecretKey() == null ? "" : properties.getSecretKey().trim();
|
||||
if (secret.length() < 16) {
|
||||
throw new ServiceException("请先配置至少16位的 AIHR_WEB_AI_SECRET_KEY");
|
||||
if (databaseKey != null) {
|
||||
return databaseKey;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (databaseKey != null) {
|
||||
return databaseKey;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` (
|
||||
`config_key` varchar(64) NOT NULL,
|
||||
`config_value` varchar(255) NOT NULL,
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
""");
|
||||
byte[] random = new byte[32];
|
||||
RANDOM.nextBytes(random);
|
||||
jdbcTemplate.update("""
|
||||
insert ignore into aihr_web_ai_secret (config_key, config_value, create_time)
|
||||
values (?, ?, now())
|
||||
""", CONFIG_KEY, Base64.getEncoder().encodeToString(random));
|
||||
String secret = jdbcTemplate.queryForObject("""
|
||||
select config_value from aihr_web_ai_secret where config_key = ?
|
||||
""", String.class, CONFIG_KEY);
|
||||
if (secret == null || secret.length() < 16) {
|
||||
throw new ServiceException("全网检索数据库主密钥无效");
|
||||
}
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
|
||||
return new SecretKeySpec(digest, "AES");
|
||||
databaseKey = new SecretKeySpec(digest, "AES");
|
||||
return databaseKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -22,6 +22,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -172,9 +174,10 @@ class AihrWebAiSecurityTest {
|
||||
@Test
|
||||
@Tag("dev")
|
||||
void databaseProviderSecretUsesAuthenticatedEncryption() {
|
||||
AihrWebAiProperties properties = new AihrWebAiProperties();
|
||||
properties.setSecretKey("unit-test-secret-key-32-bytes-long");
|
||||
AihrWebSecretCodec codec = new AihrWebSecretCodec(properties);
|
||||
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(String.class), eq("provider-encryption-v1")))
|
||||
.thenReturn("unit-test-secret-key-32-bytes-long");
|
||||
AihrWebSecretCodec codec = new AihrWebSecretCodec(jdbcTemplate);
|
||||
|
||||
String encrypted = codec.encrypt("tvly-test-secret");
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 全网 AI 提供方密钥的数据库主密钥;不通过环境变量或通用参数接口暴露。
|
||||
CREATE TABLE IF NOT EXISTS `aihr_web_ai_secret` (
|
||||
`config_key` varchar(64) NOT NULL COMMENT '内部配置键',
|
||||
`config_value` varchar(255) NOT NULL COMMENT '随机主密钥,仅后端读取',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`config_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='全网AI内部密钥配置';
|
||||
|
||||
INSERT IGNORE INTO `aihr_web_ai_secret` (`config_key`, `config_value`)
|
||||
VALUES ('provider-encryption-v1', TO_BASE64(RANDOM_BYTES(32)));
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。
|
||||
|
||||
2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。迁移后发布门禁统一为 `39/39`。
|
||||
2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。全网 AI 提供方加密主密钥改为数据库内部配置后,发布门禁统一为 `40/40`。
|
||||
|
||||
## 发布前置条件
|
||||
|
||||
@@ -53,6 +53,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql
|
||||
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql
|
||||
```
|
||||
|
||||
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
|
||||
@@ -74,7 +75,7 @@ WHERE table_schema = DATABASE()
|
||||
'aihr_practice_question_feedback',
|
||||
'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question',
|
||||
'aihr_onboard_exam_attempt', 'aihr_onboard_exam_answer',
|
||||
'aihr_web_search_provider', 'aihr_web_ai_query_audit',
|
||||
'aihr_web_search_provider', 'aihr_web_ai_secret', 'aihr_web_ai_query_audit',
|
||||
'aihr_community_question', 'aihr_community_answer',
|
||||
'aihr_incentive_rule', 'aihr_points_ledger',
|
||||
'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link',
|
||||
@@ -125,7 +126,7 @@ GROUP BY question_type
|
||||
ORDER BY question_type;
|
||||
```
|
||||
|
||||
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
|
||||
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
|
||||
|
||||
## 迁移后应用回归
|
||||
|
||||
|
||||
+1
-2
@@ -79,10 +79,9 @@ AIHR_ORG_SYNC_SIGNING_SECRET=
|
||||
AIHR_AI_RUNTIME_ENABLED=true
|
||||
AIHR_AI_CHAT_ENABLED=true
|
||||
AIHR_AI_SPEECH_ENABLED=true
|
||||
AIHR_WEB_AI_SECRET_KEY=replace-with-at-least-16-random-characters
|
||||
```
|
||||
|
||||
`AIHR_WEB_AI_SECRET_KEY` 用于加密数据库中的全网检索访问密钥,必须配置为至少 16 位的独立随机值;缺失或长度不足时不能保存或读取提供方密钥。全网检索提供方只接受公网 HTTPS 地址,且每次修改地址或密钥后都必须重新连接测试,测试成功后才能启用。
|
||||
全网检索访问密钥使用数据库表 `aihr_web_ai_secret` 中自动生成的主密钥加密,不依赖环境变量,也不通过通用参数接口回显。数据库迁移、备份和恢复必须同时保留该表;丢失或修改主密钥后需要重新保存提供方密钥。全网检索提供方只接受公网 HTTPS 地址,且每次修改地址或密钥后都必须重新连接测试,测试成功后才能启用。
|
||||
|
||||
`application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ FROM (
|
||||
UNION ALL SELECT 'aihr_onboard_exam_attempt'
|
||||
UNION ALL SELECT 'aihr_onboard_exam_answer'
|
||||
UNION ALL SELECT 'aihr_web_search_provider'
|
||||
UNION ALL SELECT 'aihr_web_ai_secret'
|
||||
UNION ALL SELECT 'aihr_web_ai_query_audit'
|
||||
UNION ALL SELECT 'aihr_community_question'
|
||||
UNION ALL SELECT 'aihr_community_answer'
|
||||
|
||||
Reference in New Issue
Block a user