feat: 重构为前后端分离架构并完善设备端演示
- 新增 Web 用户端(登录体系 + 统一 REST API 调用) - 后端增加用户认证、统一 ApiResponse、CORS 支持 - Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层 - 完善 README 架构说明与密码学原理文档 - 新增 .gitignore 与持久化数据表说明
This commit is contained in:
@@ -1,39 +1,62 @@
|
||||
package com.secure.demo.controller;
|
||||
|
||||
import com.secure.demo.auth.TokenService;
|
||||
import com.secure.demo.common.ApiException;
|
||||
import com.secure.demo.common.ApiResponse;
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import com.secure.demo.crypto.RsaUtil;
|
||||
import com.secure.demo.model.Device;
|
||||
import com.secure.demo.model.EncryptedPhoto;
|
||||
import com.secure.demo.repository.EncryptedPhotoRepository;
|
||||
import com.secure.demo.service.DeviceBindingService;
|
||||
import com.secure.demo.service.DeviceBindingService.RecoveryResponse;
|
||||
import com.secure.demo.service.KeyManagementService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 设备安全 API 控制器
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────┐
|
||||
* │ 接口清单 │
|
||||
* ├─────────────────────────────────────────────────────────────┤
|
||||
* │ POST /api/device/register 设备注册(SN + 公钥) │
|
||||
* │ POST /api/device/bind 用户绑定设备 │
|
||||
* │ POST /api/device/sms/send 发送短信验证码 │
|
||||
* │ POST /api/photo/upload 上传加密照片 │
|
||||
* │ POST /api/device/recover 恢复设备(短信验证) │
|
||||
* │ POST /api/photo/recover 恢复后获取照片 DEK │
|
||||
* │ GET /api/photo/{id}/decrypt 用户下载并解密照片 │
|
||||
* └─────────────────────────────────────────────────────────────┘
|
||||
* 设备安全 API 控制器(前后端分离版)
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────────────┐
|
||||
* │ 接口分组(模拟真实场景) │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 公开: │
|
||||
* │ POST /api/auth/register 用户注册(返回 Token) │
|
||||
* │ POST /api/auth/login 用户登录(返回 Token) │
|
||||
* │ GET /api/health 健康检查 │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 设备端(无登录体系,信任边界=TEE 密钥/SN/Recovery Token): │
|
||||
* │ POST /api/device/register 设备注册(SN + 公钥) │
|
||||
* │ POST /api/photo/upload 上传加密照片(设备签名) │
|
||||
* │ POST /api/photo/recover 恢复后获取照片 DEK │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 用户端(需要 Authorization: Bearer <token>): │
|
||||
* │ POST /api/device/bind 绑定设备(SN) │
|
||||
* │ POST /api/device/sms/send 发送短信验证码 │
|
||||
* │ POST /api/device/recover 恢复授权(短信验证) │
|
||||
* │ GET /api/photo/{id}/decrypt 下载并解密照片 │
|
||||
* │ GET /api/user/photos 我的照片列表(新增) │
|
||||
* │ GET /api/user/devices 我的设备列表(新增) │
|
||||
* └────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* 鉴权优先级(用户端接口):Bearer Token > X-User-Id Header(Android
|
||||
* demo 兼容)> body.userId(集成测试兼容)。生产环境只保留 Bearer Token。
|
||||
*
|
||||
* 所有响应统一为 ApiResponse{code, message, data},前端只依赖该契约。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -41,102 +64,148 @@ public class DeviceController {
|
||||
|
||||
private final KeyManagementService keyManagementService;
|
||||
private final DeviceBindingService deviceBindingService;
|
||||
private final EncryptedPhotoRepository photoRepository;
|
||||
private final TokenService tokenService;
|
||||
|
||||
// 照片数据库(生产环境替换为 JPA/MinIO)
|
||||
private final Map<String, EncryptedPhoto> photoDB = new ConcurrentHashMap<>();
|
||||
// 密文文件落盘根目录(来自配置 app.upload.dir,默认 ./uploads)
|
||||
private final Path uploadRoot;
|
||||
|
||||
public DeviceController(KeyManagementService keyManagementService,
|
||||
DeviceBindingService deviceBindingService) {
|
||||
DeviceBindingService deviceBindingService,
|
||||
EncryptedPhotoRepository photoRepository,
|
||||
TokenService tokenService,
|
||||
@Value("${app.upload.dir:./uploads}") String uploadDir) {
|
||||
this.keyManagementService = keyManagementService;
|
||||
this.deviceBindingService = deviceBindingService;
|
||||
this.photoRepository = photoRepository;
|
||||
this.tokenService = tokenService;
|
||||
this.uploadRoot = Paths.get(uploadDir);
|
||||
// 启动时确保上传目录存在
|
||||
try {
|
||||
Files.createDirectories(this.uploadRoot);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("无法创建上传目录: " + this.uploadRoot, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册 ====================
|
||||
// ==================== 0. 健康检查(公开) ====================
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后调用
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "publicKeyBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "message": "Device registered successfully"
|
||||
* }
|
||||
* Request: { "sn": "SN-DEMO-001", "publicKeyBase64": "..." }
|
||||
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/register")
|
||||
public ResponseEntity<?> registerDevice(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String publicKeyBase64 = req.get("publicKeyBase64");
|
||||
|
||||
if (sn == null || publicKeyBase64 == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "sn and publicKeyBase64 required"));
|
||||
throw new IllegalArgumentException("sn and publicKeyBase64 required");
|
||||
}
|
||||
|
||||
Device device = deviceBindingService.registerDevice(sn, publicKeyBase64);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", device.getDeviceId(),
|
||||
"sn", device.getSn(),
|
||||
"message", "Device registered successfully"
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备 ====================
|
||||
/**
|
||||
* 查询设备注册/绑定状态(设备端,无登录,App 启动时调用以复用已有状态)。
|
||||
*
|
||||
* 用途:App 每次重启后,先查询「该 SN 是否已注册/已绑定」,避免重复注册与绑定。
|
||||
*
|
||||
* Request: GET /api/device/status?sn=SN-DEMO-001
|
||||
* Response: data = {
|
||||
* "registered": true, // 该 SN 是否已注册
|
||||
* "bound": true, // 是否已绑定用户
|
||||
* "active": true, // 是否激活
|
||||
* "deviceId": "...", // 已注册时返回
|
||||
* "userId": "...", // 已绑定时返回
|
||||
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
|
||||
* }
|
||||
*/
|
||||
@GetMapping("/device/status")
|
||||
public ApiResponse<Map<String, Object>> deviceStatus(@RequestParam("sn") String sn) {
|
||||
if (sn == null || sn.isBlank()) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
boolean registered = device != null;
|
||||
boolean bound = registered && device.getUserId() != null && !device.getUserId().isBlank();
|
||||
boolean active = registered && device.isActive();
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"registered", registered,
|
||||
"bound", bound,
|
||||
"active", active,
|
||||
"deviceId", registered ? device.getDeviceId() : "",
|
||||
"userId", bound ? device.getUserId() : "",
|
||||
"publicKeyBase64", registered ? device.getPublicKeyBase64() : ""
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户登录后绑定 SN
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "userId": "user-001",
|
||||
* "sn": "SN-DEMO-001"
|
||||
* }
|
||||
* Request: { "sn": "SN-DEMO-001" } (userId 由 Token / Header 解析)
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/bind")
|
||||
public ResponseEntity<?> bindDevice(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
public ApiResponse<Map<String, String>> bindDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
|
||||
if (userId == null || sn == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "userId and sn required"));
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
|
||||
// Demo:用户不存在则自动注册(生产环境从用户体系获取)
|
||||
// 已登录用户不存在时自动注册(生产环境用户一定已存在,此分支仅为兼容旧 demo)
|
||||
if (!keyManagementService.userExists(userId)) {
|
||||
String phone = req.getOrDefault("phone", "13800138000");
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
}
|
||||
|
||||
deviceBindingService.bindDeviceToUser(userId, sn);
|
||||
return ResponseEntity.ok(Map.of("message", "Device bound to user successfully"));
|
||||
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码 ====================
|
||||
// ==================== 3. 短信验证码(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* 发送短信验证码(登录态防短信轰炸)
|
||||
*
|
||||
* Request: { "phone": "13800138000" }
|
||||
* Request: { "phone": "13800138000" }
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/sms/send")
|
||||
public ResponseEntity<?> sendSms(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
// 必须登录才能发短信(防轰炸)
|
||||
resolveUserId(httpRequest, req);
|
||||
String phone = req.get("phone");
|
||||
if (phone == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "phone required"));
|
||||
throw new IllegalArgumentException("phone required");
|
||||
}
|
||||
deviceBindingService.sendSmsCode(phone);
|
||||
return ResponseEntity.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
}
|
||||
|
||||
// ==================== 4. 上传加密照片 ====================
|
||||
// ==================== 4. 上传加密照片(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备上传加密照片(信封加密)
|
||||
* 设备上传加密照片(信封加密,设备私钥签名防伪)
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
@@ -148,15 +217,9 @@ public class DeviceController {
|
||||
* "metadataSignature": "...", <-- 设备私钥签名
|
||||
* "metadata": "SN-DEMO-001|ts|photo-001"
|
||||
* }
|
||||
*
|
||||
* 服务端处理:
|
||||
* 1. 查找设备 & 验证归属
|
||||
* 2. 验签(TODO:用设备公钥验证 metadataSignature)
|
||||
* 3. 用 UK 加密 DEK(信封加密)
|
||||
* 4. 存储密文 + 加密后的 DEK
|
||||
*/
|
||||
@PostMapping("/photo/upload")
|
||||
public ResponseEntity<?> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String photoId = req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", ""));
|
||||
String ciphertextBase64 = req.get("ciphertextBase64");
|
||||
@@ -168,148 +231,118 @@ public class DeviceController {
|
||||
// 1. 查找设备
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Device not registered for SN: " + sn));
|
||||
throw new ApiException(404, "Device not registered for SN: " + sn);
|
||||
}
|
||||
if (!device.isActive()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not active. Please complete recovery."));
|
||||
throw new SecurityException("Device not active. Please complete recovery.");
|
||||
}
|
||||
|
||||
String userId = device.getUserId();
|
||||
if (userId == null) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not bound to any user"));
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
|
||||
// 2. TODO: 验签
|
||||
// PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
// boolean valid = SignatureUtil.verify(metadata, metadataSignature, devicePubKey);
|
||||
// if (!valid) return 403;
|
||||
// 2. 验签:用设备公钥验证 metadataSignature(SHA256withRSA),防伪/防篡改
|
||||
if (metadata == null || metadataSignature == null || metadataSignature.isEmpty()) {
|
||||
throw new IllegalArgumentException("metadata and metadataSignature required");
|
||||
}
|
||||
// 元数据必须以设备 SN 开头,防止跨设备重放他人签名
|
||||
if (!metadata.startsWith(sn + "|")) {
|
||||
throw new SecurityException("metadata must be bound to this device SN");
|
||||
}
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
if (!RsaUtil.verifySignature(metadata, metadataSignature, devicePubKey)) {
|
||||
throw new SecurityException("Metadata signature verification failed");
|
||||
}
|
||||
|
||||
// 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")
|
||||
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
|
||||
|
||||
// 4. 存储
|
||||
// 4. 密文落盘
|
||||
String filePath;
|
||||
try {
|
||||
filePath = writeCiphertextToFile(photoId, ciphertextBase64);
|
||||
} catch (IOException e) {
|
||||
throw new ApiException(500, "Failed to save ciphertext file: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 5. 元数据存数据库
|
||||
EncryptedPhoto photo = new EncryptedPhoto(
|
||||
photoId, device.getDeviceId(), userId,
|
||||
ciphertextBase64, ivBase64,
|
||||
filePath, ivBase64,
|
||||
encryptedDek, metadataSignature
|
||||
);
|
||||
photoDB.put(photoId, photo);
|
||||
photoRepository.save(photo);
|
||||
deviceBindingService.addPhotoToUser(userId, photoId);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
"message", "Photo uploaded and encrypted successfully"
|
||||
"filePath", filePath,
|
||||
"message", "Photo uploaded, encrypted and saved successfully"
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 5. 恢复设备(核心) ====================
|
||||
// ==================== 5. 恢复授权(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新绑定 + 授权
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "userId": "user-001",
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "smsCode": "123456",
|
||||
* "newPublicKeyBase64": "MFkwEwYHKoZIzj0CAQY..."
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "encryptedRecoveryToken": "...", <-- 用新设备公钥加密
|
||||
* "nonce": "...",
|
||||
* "message": "..."
|
||||
* }
|
||||
*
|
||||
* 安全链路:
|
||||
* 1. 验证短信 → 确认是用户本人
|
||||
* 2. 确认 SN 归属 → 确认设备所有权
|
||||
* 3. 更新公钥 → 恢复出厂后密钥对已变
|
||||
* 4. 生成 Token → 用新公钥加密下发
|
||||
* 5. 旧设备自动失效(TEE 私钥已销毁)
|
||||
* Request: { "sn": "...", "smsCode": "000000", "newPublicKeyBase64": "..." }
|
||||
* Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/recover")
|
||||
public ResponseEntity<?> recoverDevice(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
String smsCode = req.get("smsCode");
|
||||
String newPublicKeyBase64 = req.get("newPublicKeyBase64");
|
||||
|
||||
if (userId == null || sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "userId, sn, smsCode, newPublicKeyBase64 all required"));
|
||||
if (sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn, smsCode, newPublicKeyBase64 all required");
|
||||
}
|
||||
|
||||
try {
|
||||
RecoveryResponse resp = deviceBindingService.recoverDevice(
|
||||
userId, sn, smsCode, newPublicKeyBase64
|
||||
);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"deviceId", resp.deviceId,
|
||||
"encryptedRecoveryToken", resp.encryptedRecoveryToken,
|
||||
"nonce", resp.nonce,
|
||||
"message", "Recovery authorized. Device can now fetch DEKs."
|
||||
));
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
RecoveryResponse resp = deviceBindingService.recoverDevice(
|
||||
userId, sn, smsCode, newPublicKeyBase64
|
||||
);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", resp.deviceId,
|
||||
"encryptedRecoveryToken", resp.encryptedRecoveryToken,
|
||||
"nonce", resp.nonce,
|
||||
"message", "Recovery authorized. Device can now fetch DEKs."
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 6. 恢复后获取照片 DEK ====================
|
||||
// ==================== 6. 恢复后获取照片 DEK(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备用 Recovery Token 获取该用户所有照片的 DEK
|
||||
* 服务端用设备当前公钥逐一加密 DEK 后下发
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "recoveryToken": "...",
|
||||
* "userId": "user-001"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "photoCount": 3,
|
||||
* "deks": [
|
||||
* {"photoId": "p1", "encryptedDekBase64": "..."},
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
* Request: { "deviceId": "...", "recoveryToken": "...", "userId": "user-001" }
|
||||
* Response: data = { "deviceId": "...", "photoCount": 3, "deks": [{"photoId","encryptedDekBase64"}] }
|
||||
*/
|
||||
@PostMapping("/photo/recover")
|
||||
public ResponseEntity<?> recoverPhotos(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req) {
|
||||
String deviceId = req.get("deviceId");
|
||||
String recoveryToken = req.get("recoveryToken");
|
||||
String userId = req.get("userId");
|
||||
|
||||
if (deviceId == null || recoveryToken == null || userId == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "deviceId, recoveryToken, userId all required"));
|
||||
throw new IllegalArgumentException("deviceId, recoveryToken, userId all required");
|
||||
}
|
||||
|
||||
// 1. 验证设备
|
||||
Device device = deviceBindingService.getDevice(deviceId);
|
||||
if (device == null || !device.isActive()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not active"));
|
||||
throw new SecurityException("Device not active");
|
||||
}
|
||||
if (!userId.equals(device.getUserId())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Token does not match device owner"));
|
||||
throw new SecurityException("Token does not match device owner");
|
||||
}
|
||||
|
||||
// 2. TODO: 解析并验证 recoveryToken 中的 nonce + 时间窗口
|
||||
// Token 格式: userId|deviceId|timestamp|nonce
|
||||
// 验证 deviceId 匹配、时间在窗口内、nonce 未重放
|
||||
// 2. 验证 recoveryToken:deviceId 匹配 + 5 分钟时间窗口 + nonce 一次性防重放
|
||||
// Token 明文格式: userId|deviceId|timestamp|nonce(设备用 TEE 私钥解密后回传)
|
||||
if (!deviceBindingService.validateRecoveryToken(deviceId, recoveryToken)) {
|
||||
throw new SecurityException("Invalid, expired or replayed recovery token");
|
||||
}
|
||||
|
||||
// 3. 获取设备公钥,用于包裹 DEK 下发
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
@@ -317,7 +350,7 @@ public class DeviceController {
|
||||
// 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发
|
||||
List<Map<String, String>> dekList = new ArrayList<>();
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoDB.get(photoId);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
|
||||
// 用 UK 解出明文 DEK
|
||||
@@ -332,66 +365,150 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", deviceId,
|
||||
"photoCount", dekList.size(),
|
||||
"deks", dekList
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 7. 用户下载照片(解密) ====================
|
||||
// ==================== 7. 下载解密照片(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片
|
||||
*
|
||||
* 流程:
|
||||
* 1. 鉴权(X-User-Id Header)
|
||||
* 2. 检查 photo 归属
|
||||
* 3. UK 解密 DEK → DEK 解密照片
|
||||
* 4. 明文通过 HTTPS 返回
|
||||
* 流程:鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 解照片
|
||||
* Response: data = { "photoId": "...", "plaintextBase64": "..." }
|
||||
*/
|
||||
@GetMapping("/photo/{photoId}/decrypt")
|
||||
public ResponseEntity<?> downloadAndDecrypt(@PathVariable String photoId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
EncryptedPhoto photo = photoDB.get(photoId);
|
||||
public ApiResponse<Map<String, String>> downloadAndDecrypt(@PathVariable String photoId,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
}
|
||||
|
||||
// 归属检查
|
||||
if (!userId.equals(photo.getUserId())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Not your photo"));
|
||||
throw new SecurityException("Not your photo");
|
||||
}
|
||||
|
||||
try {
|
||||
// 从文件读取密文并转 Base64 字符串
|
||||
String ciphertextBase64 = readCiphertextFromFile(photo.getFilePath());
|
||||
|
||||
// 1. UK 解密 DEK
|
||||
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
|
||||
|
||||
// 2. DEK 解密照片
|
||||
byte[] plaintext = AesGcmUtil.decrypt(
|
||||
photo.getCiphertextBase64(),
|
||||
ciphertextBase64,
|
||||
photo.getIvBase64(),
|
||||
dek
|
||||
);
|
||||
|
||||
// 3. 返回明文(生产环境用 StreamingResponseBody 大文件流式传输)
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Content-Disposition",
|
||||
"attachment; filename=\"" + photoId + ".bin\"")
|
||||
.body(Base64.getEncoder().encodeToString(plaintext));
|
||||
// 3. 返回明文(JSON Base64,生产环境可用 StreamingResponseBody 流式传输)
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
"plaintextBase64", Base64.getEncoder().encodeToString(plaintext)
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Decryption failed: " + e.getMessage()));
|
||||
throw new ApiException(500, "Decryption failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 8. 健康检查 ====================
|
||||
// ==================== 8. 我的照片列表(用户端,登录) ====================
|
||||
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<?> health() {
|
||||
return ResponseEntity.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
/**
|
||||
* 当前登录用户的照片列表(仅 photoId,不含密文/DEK)
|
||||
*
|
||||
* Response: data = { "photos": ["photo-001", "photo-002", ...] }
|
||||
*/
|
||||
@GetMapping("/user/photos")
|
||||
public ApiResponse<Map<String, Object>> getUserPhotos(HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
List<String> photoIds = deviceBindingService.getUserPhotoIds(userId);
|
||||
return ApiResponse.ok(Map.of("photos", photoIds));
|
||||
}
|
||||
|
||||
// ==================== 9. 我的设备列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 当前登录用户的设备列表
|
||||
*
|
||||
* Response: data = { "devices": [ { "deviceId","sn","active","bindTime","lastRecoveryTime" } ] }
|
||||
*/
|
||||
@GetMapping("/user/devices")
|
||||
public ApiResponse<Map<String, Object>> getUserDevices(HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
List<Device> devices = deviceBindingService.getDevicesByUser(userId);
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (Device d : devices) {
|
||||
list.add(Map.of(
|
||||
"deviceId", d.getDeviceId(),
|
||||
"sn", d.getSn(),
|
||||
"active", d.isActive(),
|
||||
"bindTime", d.getBindTime(),
|
||||
"lastRecoveryTime", d.getLastRecoveryTime()
|
||||
));
|
||||
}
|
||||
return ApiResponse.ok(Map.of("devices", list));
|
||||
}
|
||||
|
||||
// ==================== 鉴权辅助 ====================
|
||||
|
||||
/**
|
||||
* 解析当前请求的用户 ID。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. Authorization: Bearer <token>(Web 用户端登录后携带,真实场景唯一方式)
|
||||
* 2. X-User-Id Header(Android 设备端 demo 兼容,AuthInterceptor 自动注入)
|
||||
* 3. body.userId(集成测试 / 旧调用兼容)
|
||||
*
|
||||
* 均无 → 401 Unauthorized
|
||||
*/
|
||||
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
|
||||
// 1. Bearer Token(真实场景)
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
String userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
if (userId != null) {
|
||||
return userId;
|
||||
}
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
// 2. X-User-Id Header(Android demo 兼容)
|
||||
String headerUserId = request.getHeader("X-User-Id");
|
||||
if (headerUserId != null && !headerUserId.isBlank()) {
|
||||
return headerUserId;
|
||||
}
|
||||
// 3. body.userId(集成测试 / 旧调用兼容)
|
||||
if (body != null && body.get("userId") != null && !body.get("userId").isBlank()) {
|
||||
return body.get("userId");
|
||||
}
|
||||
throw new UnauthorizedException("Login required: missing Authorization Bearer token");
|
||||
}
|
||||
|
||||
// ==================== 文件落盘辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 将 Base64 密文解码后写入上传目录,文件名 = {photoId}.enc
|
||||
*
|
||||
* @return 文件绝对路径
|
||||
*/
|
||||
private String writeCiphertextToFile(String photoId, String ciphertextBase64) throws IOException {
|
||||
Path target = uploadRoot.resolve(photoId + ".enc");
|
||||
byte[] raw = Base64.getDecoder().decode(ciphertextBase64);
|
||||
Files.write(target, raw);
|
||||
return target.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)
|
||||
*/
|
||||
private String readCiphertextFromFile(String filePath) throws IOException {
|
||||
byte[] raw = Files.readAllBytes(Paths.get(filePath));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user