feat(android): add device activation flow and update dependencies

重构项目架构,引入启动页(SplashActivity)检查激活状态,未激活设备引导至激活页(ActivationActivity)完成provision+token流程。集成Retrofit+RxJava3网络层、Room数据库、Lifecycle组件、MMKV等依赖,升级OkHttp/Gson版本,并将构建配置从旧项目全面迁移至新项目结构。
This commit is contained in:
TongTongStudio
2026-08-03 00:25:51 +08:00
parent 61398ff4ff
commit ded6fe5ab7
105 changed files with 5363 additions and 1503 deletions

View File

@@ -1,70 +1,91 @@
package com.ttstd.signaling.config;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.security.TokenUtils;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.service.AdminService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口
* 后台管理接口鉴权过滤器:校验 {@code Authorization: Bearer admt_...} 管理员令牌
*
* <p>支持两种凭据,满足其一即可通过:
* <ol>
* <li>请求头 {@code X-Admin-Token} 与配置令牌一致(兼容既有管理后台登录方式)</li>
* <li>{@code Authorization: Bearer <token>} 且账号具备管理员角色
* (由 {@link BearerAuthFilter} 预先解析)</li>
* </ol>
*
* <p>过滤器顺序在 {@link BearerAuthFilter} 之后,以便读取其解析出的主体。
* <p>与主控端用户体系完全独立,使用 {@link AdminService} 专用令牌(前缀 admt_
* 仅对 {@code /api/admin/**} 生效;免鉴权路径(如登录、健康检查)统一由
* {@link PublicEndpoints} 管理。
*/
@Component
@Order(2)
public class AdminAuthFilter extends OncePerRequestFilter {
@Value("${admin.token:webrtc-admin-token}")
private String adminToken;
private final AdminService adminService;
public AdminAuthFilter(AdminService adminService) {
this.adminService = adminService;
}
private static final String PREFIX = "/api/admin/";
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
if (path == null || !path.startsWith(PREFIX)) {
return true;
}
// 免鉴权路径(由 PublicEndpoints 统一管理,例如登录接口)放行
return PublicEndpoints.isPublic(request);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String uri = request.getRequestURI();
if (!uri.startsWith("/api/admin")) {
filterChain.doFilter(request, response);
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
writeUnauthorized(response, "缺少管理员令牌");
return;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
if (hasValidAdminToken(request) || hasAdminPrincipal(request)) {
filterChain.doFilter(request, response);
String token = header.substring("Bearer ".length()).trim();
if (token.isEmpty()) {
writeUnauthorized(response, "管理员令牌为空");
return;
}
try {
Map<String, Object> claims = adminService.verifyToken(token);
String sub = com.ttstd.signaling.security.JwtService.claimAsString(claims, "sub");
AdminAuthPrincipal principal = new AdminAuthPrincipal(sub);
request.setAttribute("adminPrincipal", principal);
filterChain.doFilter(request, response);
} catch (AuthException e) {
writeUnauthorized(response, e.getMessage());
} catch (Exception e) {
writeUnauthorized(response, "令牌校验失败");
}
}
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员凭据\"}");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String body = "{\"error\":\"unauthorized\",\"message\":\""
+ escape(message) + "\"}";
response.getWriter().write(body);
}
private boolean hasValidAdminToken(HttpServletRequest request) {
String token = request.getHeader("X-Admin-Token");
return adminToken != null && !adminToken.isBlank()
&& TokenUtils.constantTimeEquals(adminToken, token);
private static String escape(String s) {
if (s == null) {
return "";
}
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private boolean hasAdminPrincipal(HttpServletRequest request) {
Object principal = request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL);
return principal instanceof AuthPrincipal auth && auth.admin();
}
}
}

View File

@@ -0,0 +1,15 @@
package com.ttstd.signaling.config;
/** 后台管理员鉴权主体,仅携带管理员 ID。 */
public class AdminAuthPrincipal {
private final String adminId;
public AdminAuthPrincipal(String adminId) {
this.adminId = adminId;
}
public String getAdminId() {
return adminId;
}
}

View File

@@ -28,14 +28,6 @@ public class BearerAuthFilter extends OncePerRequestFilter {
public static final String ATTR_PRINCIPAL = "authPrincipal";
/** 无需认证即可访问的端点 */
private static final Set<String> PUBLIC_PATHS = Set.of(
"/api/auth/register",
"/api/auth/login",
"/api/auth/refresh",
"/api/device/provision",
"/api/device/token");
/** 需要令牌的受保护端点前缀(用户或设备令牌均可) */
private static final Set<String> PROTECTED_PREFIXES = Set.of("/api/auth/", "/api/client/");
@@ -54,7 +46,7 @@ public class BearerAuthFilter extends OncePerRequestFilter {
FilterChain filterChain) throws ServletException, IOException {
String uri = request.getRequestURI();
if ("OPTIONS".equalsIgnoreCase(request.getMethod()) || PUBLIC_PATHS.contains(uri)) {
if (PublicEndpoints.isPublic(request)) {
filterChain.doFilter(request, response);
return;
}

View File

@@ -0,0 +1,65 @@
package com.ttstd.signaling.config;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Set;
/**
* 统一管理“无需鉴权即可访问”的端点。
*
* <p>所有免登录/免令牌的公开接口集中在此登记,避免各过滤器各自硬编码放行规则
* 导致遗漏或冲突(例如登录接口被误拦截返回 401
*
* <p>匹配规则:
* <ul>
* <li>{@link #EXACT} 中的精确路径(含任意 HTTP 方法)一律放行;</li>
* <li>{@link #PREFIXES} 中的前缀路径一律放行;</li>
* <li>OPTIONS 预检请求一律放行;</li>
* <li>其余路径交由对应鉴权过滤器处理。</li>
* </ul>
*/
public final class PublicEndpoints {
private PublicEndpoints() {
}
/** 精确免鉴权路径(不区分 HTTP 方法)。 */
public static final Set<String> EXACT = Set.of(
// 主控端用户体系
"/api/auth/register",
"/api/auth/login",
"/api/auth/refresh",
// 设备激活 / 设备令牌
"/api/device/provision",
"/api/device/token",
// 后台管理员:登录接口免鉴权(其余后台接口均需 admt_ 令牌)
"/api/admin/login",
// 后台健康检查(运维探针)
"/api/admin/health"
);
/** 免鉴权前缀。 */
public static final Set<String> PREFIXES = Set.of(
// WebSocket 握手前的公开信令协商路径(如有)
);
/** 判断请求是否免鉴权。 */
public static boolean isPublic(HttpServletRequest request) {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String path = request.getServletPath();
if (path == null) {
return false;
}
if (EXACT.contains(path)) {
return true;
}
for (String prefix : PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,51 @@
package com.ttstd.signaling.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
/**
* 启动期数据库 schema 自愈:清理历史遗留的废弃表。
*
* <p>旧版本曾存在独立的 {@code password_history} 表(含外键
* {@code fk_pwdhist_user} 指向 {@code app_user.user_id})。当前代码已将其合并为
* {@code app_user.password_history} 字段,不再使用该表,但其外键会阻止 Hibernate
* 在 {@code ddl-auto: update} 下对 {@code app_user.user_id} 列做必要迁移,导致启动失败。
*
* <p>本组件在 JPA 执行 DDL 之前(仅依赖 DataSource不依赖任何 JPA 组件)幂等删除该废弃表。
* 可通过 {@code security.schema.cleanup-enabled=false} 关闭。
*/
@Component
@Order(0)
@ConditionalOnProperty(name = "security.schema.cleanup-enabled", havingValue = "true", matchIfMissing = true)
public class SchemaCleanup {
private static final Logger logger = LoggerFactory.getLogger(SchemaCleanup.class);
private final DataSource dataSource;
public SchemaCleanup(DataSource dataSource) {
this.dataSource = dataSource;
}
@PostConstruct
public void cleanup() {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
// 仅清理确已废弃、且代码无任何实体/Repository 引用的遗留表
stmt.execute("DROP TABLE IF EXISTS password_history");
logger.info("已清理废弃表 password_history若存在");
} catch (Exception e) {
// 清理失败不应阻断启动;记录告警,留给人工处理
logger.warn("清理废弃表 password_history 失败(可忽略,手动 DROP 亦可): {}", e.toString());
}
}
}

View File

@@ -2,8 +2,11 @@ package com.ttstd.signaling.config;
import com.ttstd.signaling.security.SecurityProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 启用安全相关配置属性绑定与定时任务。
@@ -12,4 +15,13 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableConfigurationProperties(SecurityProperties.class)
@EnableScheduling
public class SecurityBeansConfig {
/**
* 全局密码编码器 Bean。供 AdminService后台管理员注入使用
* 与 AccountService 内部使用的 BCrypt(strength=12) 保持一致。
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

View File

@@ -4,15 +4,19 @@ import com.ttstd.signaling.manager.ConnectionRequestManager;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.manager.SignalMetrics;
import com.ttstd.signaling.model.AbuseReport;
import com.ttstd.signaling.model.AdminAccount;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.DeviceInfo;
import com.ttstd.signaling.model.UserAccount;
import com.ttstd.signaling.service.AbuseReportService;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.AdminService;
import com.ttstd.signaling.service.AuditService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -30,7 +34,7 @@ import java.util.stream.Collectors;
/**
* 后台管理 REST 接口:为 WebRTCSignalServerWeb 提供服务器运行态查询。
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 统一鉴权
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 校验管理员令牌
*/
@RestController
@RequestMapping("/api/admin")
@@ -44,6 +48,7 @@ public class AdminController {
private final AuditService auditService;
private final BindingService bindingService;
private final AbuseReportService abuseReportService;
private final AdminService adminService;
private final long startTime = System.currentTimeMillis();
public AdminController(SessionManager sessionManager,
@@ -53,7 +58,8 @@ public class AdminController {
DeviceIdentityService deviceIdentityService,
AuditService auditService,
BindingService bindingService,
AbuseReportService abuseReportService) {
AbuseReportService abuseReportService,
AdminService adminService) {
this.sessionManager = sessionManager;
this.connectionRequestManager = connectionRequestManager;
this.metrics = metrics;
@@ -62,6 +68,7 @@ public class AdminController {
this.auditService = auditService;
this.bindingService = bindingService;
this.abuseReportService = abuseReportService;
this.adminService = adminService;
}
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
@@ -129,12 +136,158 @@ public class AdminController {
return data;
}
// ==================== 管理员登录 ====================
/**
* 后台管理员登录,签发独立的管理员令牌(前缀 admt_
* 该接口免鉴权;请求体:{@code {"username": "admin", "password": "***"}}。
*/
@PostMapping("/login")
public Map<String, Object> adminLogin(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
String password = (String) body.get("password");
return adminService.login(username, password);
}
// ==================== 管理员管理 ====================
/** 当前登录的管理员自身信息。 */
@GetMapping("/me")
public Map<String, Object> adminMe(jakarta.servlet.http.HttpServletRequest request) {
String adminId = currentAdminId(request);
return Map.of("adminId", adminId);
}
/** 管理员列表。 */
@GetMapping("/admins")
public List<Map<String, Object>> listAdmins() {
return adminService.listAdmins().stream().map(AdminController::toAdminView).toList();
}
/**
* 新增后台管理员。
* 请求体:{@code {"username": "op1", "password": "***", "displayName": "运维1"}}。
*/
@PostMapping("/admins")
public Map<String, Object> createAdmin(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
String password = (String) body.get("password");
String displayName = (String) body.get("displayName");
AdminAccount created = adminService.createAdmin(username, password, displayName);
return Map.of("success", true, "admin", toAdminView(created));
}
/** 禁用管理员(保留记录,无法登录)。 */
@PostMapping("/admins/{adminId}/disable")
public Map<String, Object> disableAdmin(@PathVariable String adminId) {
adminService.disableAdmin(adminId);
return Map.of("success", true, "adminId", adminId);
}
/** 启用管理员。 */
@PostMapping("/admins/{adminId}/enable")
public Map<String, Object> enableAdmin(@PathVariable String adminId) {
adminService.enableAdmin(adminId);
return Map.of("success", true, "adminId", adminId);
}
/** 删除管理员(不可删除最后一个启用状态的管理员)。 */
@DeleteMapping("/admins/{adminId}")
public Map<String, Object> deleteAdmin(@PathVariable String adminId) {
adminService.deleteAdmin(adminId);
return Map.of("success", true, "adminId", adminId);
}
/** 重置管理员密码。请求体:{@code {"password": "***"}}。 */
@PostMapping("/admins/{adminId}/reset-password")
public Map<String, Object> resetAdminPassword(@PathVariable String adminId,
@RequestBody Map<String, Object> body) {
String password = (String) body.get("password");
if (password == null || password.isBlank()) {
return Map.of("success", false, "message", "新密码不能为空");
}
adminService.resetPassword(adminId, password);
return Map.of("success", true, "adminId", adminId);
}
private String currentAdminId(jakarta.servlet.http.HttpServletRequest request) {
Object principal = request.getAttribute("adminPrincipal");
if (principal instanceof com.ttstd.signaling.config.AdminAuthPrincipal p) {
return p.getAdminId();
}
throw com.ttstd.signaling.security.AuthException.unauthorized("未识别管理员身份");
}
private static Map<String, Object> toAdminView(AdminAccount a) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("adminId", a.getAdminId());
item.put("username", a.getUsername());
item.put("displayName", a.getDisplayName());
item.put("status", a.getStatus().name());
item.put("createdAt", a.getCreatedAt().toEpochMilli());
item.put("lastLoginAt", a.getLastLoginAt() == null ? null : a.getLastLoginAt().toEpochMilli());
return item;
}
// ==================== 账号管理 ====================
/** 主控端账号列表。 */
/**
* 主控端账号列表,支持按用户名关键字与状态过滤。
*
* @param keyword 用户名模糊匹配,忽略大小写
* @param status 账号状态ACTIVE / SUSPENDED / BANNED
*/
@GetMapping("/users")
public List<Map<String, Object>> users() {
return accountService.listUsers().stream().map(AdminController::toUserView).toList();
public List<Map<String, Object>> users(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) {
return accountService.listUsers().stream()
.filter(u -> keyword == null || keyword.isBlank()
|| u.getUsername().toLowerCase().contains(keyword.toLowerCase()))
.filter(u -> status == null || status.isBlank()
|| u.getStatus().name().equalsIgnoreCase(status))
.map(AdminController::toUserView)
.toList();
}
/**
* 新增账号。
*
* <p>请求体:{@code {"username": "alice", "password": "***"}}
*
* <p>此处创建的是普通用户app_user与管理员admin_account完全隔离。
* 若需创建后台管理员,请调用 {@code POST /api/admin/admins}。
*/
@PostMapping("/users")
public Map<String, Object> createUser(@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
String password = (String) body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) {
return Map.of("success", false, "message", "用户名与密码不能为空");
}
UserAccount created = accountService.adminCreateUser(username.trim(), password);
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("user", toUserView(created));
return result;
}
/** 删除账号,同时下线其全部会话。 */
@DeleteMapping("/users/{userId}")
public Map<String, Object> deleteUser(@PathVariable String userId) {
accountService.deleteUser(userId);
return Map.of("success", true, "userId", userId);
}
/** 管理员重置账号密码。请求体:{@code {"password": "***"}} */
@PostMapping("/users/{userId}/reset-password")
public Map<String, Object> resetPassword(@PathVariable String userId,
@RequestBody Map<String, Object> body) {
String password = (String) body.get("password");
if (password == null || password.isBlank()) {
return Map.of("success", false, "message", "新密码不能为空");
}
accountService.adminResetPassword(userId, password);
return Map.of("success", true, "userId", userId);
}
/** 指定账号的活跃登录会话。 */
@@ -195,21 +348,57 @@ public class AdminController {
// ==================== 设备管理 ====================
/** 已激活的被控端设备列表SN 已脱敏)。 */
/**
* 已激活的被控端设备列表SN 已脱敏)。
*
* @param keyword 按设备 UID / 型号模糊匹配
* @param status 设备状态ACTIVE / SUSPENDED / BANNED
* @param online 在线状态过滤null 表示不过滤
*/
@GetMapping("/device-accounts")
public List<Map<String, Object>> deviceAccounts() {
return deviceIdentityService.listDevices().stream().map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("deviceUid", d.getDeviceUid());
item.put("sn", d.maskedSn());
item.put("model", d.getModel());
item.put("status", d.getStatus().name());
item.put("statusReason", d.getStatusReason());
item.put("provisionedAt", d.getProvisionedAt().toEpochMilli());
item.put("lastOnlineAt", d.getLastOnlineAt() == null ? null : d.getLastOnlineAt().toEpochMilli());
item.put("online", sessionManager.isDeviceOnline(d.getDeviceUid()));
return item;
}).toList();
public List<Map<String, Object>> deviceAccounts(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) Boolean online) {
return deviceIdentityService.listDevices().stream()
.filter(d -> keyword == null || keyword.isBlank()
|| d.getDeviceUid().toLowerCase().contains(keyword.toLowerCase())
|| (d.getModel() != null && d.getModel().toLowerCase().contains(keyword.toLowerCase())))
.filter(d -> status == null || status.isBlank()
|| d.getStatus().name().equalsIgnoreCase(status))
.filter(d -> online == null || online == sessionManager.isDeviceOnline(d.getDeviceUid()))
.map(this::toDeviceView)
.toList();
}
/** 强制设备下线:吊销现有令牌并断开连接,但不改变设备状态。 */
@PostMapping("/device-accounts/{deviceUid}/kick")
public Map<String, Object> kickDevice(@PathVariable String deviceUid,
@RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? "管理员强制下线"
: (String) body.getOrDefault("reason", "管理员强制下线");
deviceIdentityService.kick(deviceUid, reason);
return Map.of("success", true, "deviceUid", deviceUid);
}
/** 删除设备记录并断开其连接。 */
@DeleteMapping("/device-accounts/{deviceUid}")
public Map<String, Object> deleteDevice(@PathVariable String deviceUid) {
deviceIdentityService.delete(deviceUid);
return Map.of("success", true, "deviceUid", deviceUid);
}
private Map<String, Object> toDeviceView(DeviceAccount d) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("deviceUid", d.getDeviceUid());
item.put("sn", d.maskedSn());
item.put("model", d.getModel());
item.put("status", d.getStatus().name());
item.put("statusReason", d.getStatusReason());
item.put("statusUntil", d.getStatusUntil() == null ? null : d.getStatusUntil().toEpochMilli());
item.put("provisionedAt", d.getProvisionedAt().toEpochMilli());
item.put("lastOnlineAt", d.getLastOnlineAt() == null ? null : d.getLastOnlineAt().toEpochMilli());
item.put("online", sessionManager.isDeviceOnline(d.getDeviceUid()));
return item;
}
/** 禁用设备并立即断开其连接。 */
@@ -243,6 +432,21 @@ public class AdminController {
"total", deviceIdentityService.getAllowlist().size());
}
/** 查询 SN 白名单。 */
@GetMapping("/device-allowlist")
public Map<String, Object> listAllowlist() {
List<String> sns = deviceIdentityService.getAllowlist().stream().sorted().toList();
return Map.of("total", sns.size(), "sns", sns);
}
/** 将指定 SN 移出白名单。 */
@DeleteMapping("/device-allowlist/{sn}")
public Map<String, Object> removeAllowlist(@PathVariable String sn) {
boolean removed = deviceIdentityService.removeFromAllowlist(sn);
return Map.of("success", removed, "sn", sn,
"total", deviceIdentityService.getAllowlist().size());
}
// ==================== 绑定 / 黑名单管理 ====================
/** 列出某设备的全部绑定关系(含已撤销)。 */
@@ -400,21 +604,21 @@ public class AdminController {
* 默认返回最近 100 条,最多 1000 条。
*/
@GetMapping("/audit")
public List<AuditService.AuditEntry> audit(
public Map<String, Object> audit(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "100") int limit,
@RequestParam(required = false) String actorId,
@RequestParam(required = false) String action,
@RequestParam(required = false) String result) {
int cap = Math.min(Math.max(limit, 1), 1000);
List<AuditService.AuditEntry> all = auditService.recent(cap);
if (actorId == null && action == null && result == null) {
return all;
}
return all.stream()
.filter(e -> actorId == null || actorId.equals(e.actorId()))
.filter(e -> action == null || action.equals(e.action()))
.filter(e -> result == null || result.equals(e.result()))
.toList();
Page<AuditService.AuditEntry> found =
auditService.search(actorId, action, result, page, limit);
Map<String, Object> body = new LinkedHashMap<>();
body.put("items", found.getContent());
body.put("page", found.getNumber());
body.put("size", found.getSize());
body.put("total", found.getTotalElements());
body.put("totalPages", found.getTotalPages());
return body;
}
private static Map<String, Object> toUserView(UserAccount u) {
@@ -424,7 +628,6 @@ public class AdminController {
item.put("status", u.getStatus().name());
item.put("statusReason", u.getStatusReason());
item.put("statusUntil", u.getStatusUntil() == null ? null : u.getStatusUntil().toEpochMilli());
item.put("admin", u.isAdmin());
item.put("locked", u.isLocked());
item.put("createdAt", u.getCreatedAt().toEpochMilli());
item.put("lastLoginAt", u.getLastLoginAt() == null ? null : u.getLastLoginAt().toEpochMilli());

View File

@@ -150,7 +150,6 @@ public class AuthController {
body.put("displayName", principal.displayName());
body.put("sessionId", principal.sessionId());
body.put("signalDeviceId", principal.deviceId());
body.put("admin", principal.admin());
// 便于客户端展示"是否已开启双因子",仅对用户身份有效
if (principal.isUser()) {
body.put("totpEnabled",

View File

@@ -6,6 +6,8 @@ package com.ttstd.signaling.model;
public enum AccountStatus {
/** 正常 */
ACTIVE,
/** 管理员被禁用(无法登录后台) */
DISABLED,
/** 临时封禁(到期自动恢复) */
SUSPENDED,
/** 永久封禁 */

View File

@@ -0,0 +1,125 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* 后台管理员账号。
*
* 与主控端 {@link UserAccount} 完全独立使用单独的数据库表admin_account
* 独立的 token 机制admt_ 前缀的 JWT不参与普通用户的注册/登录/绑定流程。
*/
@Entity
@Table(name = "admin_account")
public class AdminAccount {
@Id
@Column(name = "admin_id", length = 64, nullable = false)
private String adminId;
@Column(name = "username", length = 64, unique = true, nullable = false)
private String username;
@Column(name = "password_hash", length = 100, nullable = false)
private String passwordHash;
@Column(name = "display_name", length = 64)
private String displayName;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private AccountStatus status = AccountStatus.ACTIVE;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
@Column(name = "last_login_at")
private Instant lastLoginAt;
public AdminAccount() {
}
public AdminAccount(String adminId, String username, String passwordHash) {
this.adminId = adminId;
this.username = username;
this.passwordHash = passwordHash;
this.createdAt = Instant.now();
}
public String getAdminId() {
return adminId;
}
public void setAdminId(String adminId) {
this.adminId = adminId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public AccountStatus getStatus() {
return status;
}
public void setStatus(AccountStatus status) {
this.status = status;
}
public boolean isDisabled() {
return status == AccountStatus.DISABLED;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public Instant getLastLoginAt() {
return lastLoginAt;
}
public void setLastLoginAt(Instant lastLoginAt) {
this.lastLoginAt = lastLoginAt;
}
}

View File

@@ -0,0 +1,82 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* 安全审计日志(持久化到数据库)。
*
* <p>记录登录、激活、封禁、踢线等安全敏感操作,用于事后追溯。
*/
@Entity
@Table(name = "audit_log", indexes = {
@Index(name = "idx_audit_actor", columnList = "actor_id"),
@Index(name = "idx_audit_action", columnList = "action"),
@Index(name = "idx_audit_created", columnList = "created_at")
})
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
/** 主体类型USER / DEVICE / ADMIN */
@Column(name = "actor_type", length = 16)
private String actorType;
@Column(name = "actor_id", length = 64)
private String actorId;
@Column(name = "action", length = 48, nullable = false)
private String action;
@Column(name = "target_id", length = 64)
private String targetId;
/** SUCCESS / FAILURE */
@Column(name = "result", length = 16)
private String result;
@Column(name = "ip", length = 64)
private String ip;
@Column(name = "detail", length = 512)
private String detail;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
protected AuditLog() {
this.createdAt = Instant.now();
}
public AuditLog(String actorType, String actorId, String action,
String targetId, String result, String ip, String detail) {
this.actorType = actorType;
this.actorId = actorId;
this.action = action;
this.targetId = targetId;
this.result = result;
this.ip = ip;
this.detail = detail;
this.createdAt = Instant.now();
}
public Long getId() { return id; }
public String getActorType() { return actorType; }
public String getActorId() { return actorId; }
public String getAction() { return action; }
public String getTargetId() { return targetId; }
public String getResult() { return result; }
public String getIp() { return ip; }
public String getDetail() { return detail; }
public Instant getCreatedAt() { return createdAt; }
}

View File

@@ -9,7 +9,6 @@ package com.ttstd.signaling.model;
* @param deviceType 信令角色CONTROLLER / CONTROLLED
* @param sessionId 登录会话 ID用于精准踢线
* @param displayName 展示名(用户名 / 设备型号),仅用于日志与管理后台
* @param admin 是否为管理员账号
*/
public record AuthPrincipal(
PrincipalType principalType,
@@ -17,8 +16,7 @@ public record AuthPrincipal(
String deviceId,
DeviceType deviceType,
String sessionId,
String displayName,
boolean admin) {
String displayName) {
public boolean isUser() {
return principalType == PrincipalType.USER;

View File

@@ -53,9 +53,6 @@ public class UserAccount {
@Column(name = "token_version", nullable = false)
private long tokenVersion = 1L;
@Column(name = "is_admin", nullable = false)
private boolean admin;
/** 连续登录失败次数与锁定截止时间,用于防爆破 */
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts;
@@ -121,9 +118,6 @@ public class UserAccount {
public long getTokenVersion() { return tokenVersion; }
public void setTokenVersion(long tokenVersion) { this.tokenVersion = tokenVersion; }
public boolean isAdmin() { return admin; }
public void setAdmin(boolean admin) { this.admin = admin; }
public int getFailedAttempts() { return failedAttempts; }
public void setFailedAttempts(int failedAttempts) { this.failedAttempts = failedAttempts; }

View File

@@ -0,0 +1,17 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.AdminAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AdminAccountRepository extends JpaRepository<AdminAccount, String> {
Optional<AdminAccount> findByUsername(String username);
boolean existsByUsername(String username);
long countByStatusNot(com.ttstd.signaling.model.AccountStatus status);
}

View File

@@ -0,0 +1,37 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.AuditLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
/**
* 审计日志持久化仓库。
*/
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
/**
* 按可选条件分页查询,任一参数为 null 时视为不过滤。
*/
@Query("""
SELECT a FROM AuditLog a
WHERE (:actorId IS NULL OR a.actorId = :actorId)
AND (:action IS NULL OR a.action = :action)
AND (:result IS NULL OR a.result = :result)
ORDER BY a.id DESC
""")
Page<AuditLog> search(@Param("actorId") String actorId,
@Param("action") String action,
@Param("result") String result,
Pageable pageable);
/** 清理指定时间点之前的历史日志,返回删除条数。 */
@Modifying
@Query("DELETE FROM AuditLog a WHERE a.createdAt < :before")
int deleteOlderThan(@Param("before") Instant before);
}

View File

@@ -11,12 +11,14 @@ public class SecurityProperties {
private final Jwt jwt = new Jwt();
private final Device device = new Device();
private final Account account = new Account();
private final Admin admin = new Admin();
private final WebSocket websocket = new WebSocket();
private final Turn turn = new Turn();
public Jwt getJwt() { return jwt; }
public Device getDevice() { return device; }
public Account getAccount() { return account; }
public Admin getAdmin() { return admin; }
public WebSocket getWebsocket() { return websocket; }
public Turn getTurn() { return turn; }
@@ -67,8 +69,6 @@ public class SecurityProperties {
private int maxFailedAttempts = 5;
private long lockDurationSeconds = 900;
private int maxConcurrentSessions = 5;
private String bootstrapUsername = "admin";
private String bootstrapPassword;
public boolean isRegistrationEnabled() { return registrationEnabled; }
public void setRegistrationEnabled(boolean v) { this.registrationEnabled = v; }
@@ -81,12 +81,22 @@ public class SecurityProperties {
public int getMaxConcurrentSessions() { return maxConcurrentSessions; }
public void setMaxConcurrentSessions(int v) { this.maxConcurrentSessions = v; }
}
/** 后台管理员专用配置(独立于普通用户体系)。 */
public static class Admin {
private String bootstrapUsername = "admin";
private String bootstrapPassword;
private long tokenTtlSeconds = 3600;
public String getBootstrapUsername() { return bootstrapUsername; }
public void setBootstrapUsername(String v) { this.bootstrapUsername = v; }
public String getBootstrapPassword() { return bootstrapPassword; }
public void setBootstrapPassword(String v) { this.bootstrapPassword = v; }
public long getTokenTtlSeconds() { return tokenTtlSeconds; }
public void setTokenTtlSeconds(long v) { this.tokenTtlSeconds = v; }
}
public static class WebSocket {

View File

@@ -82,27 +82,6 @@ public class AccountService {
this.revocationListener = listener;
}
@PostConstruct
void bootstrap() {
String username = properties.getAccount().getBootstrapUsername();
String password = properties.getAccount().getBootstrapPassword();
if (username == null || username.isBlank()) {
return;
}
if (password == null || password.isBlank()) {
logger.warn("未配置 BOOTSTRAP_ADMIN_PASSWORD跳过初始管理员账号创建。"
+ "可通过环境变量配置后重启,或使用注册接口创建账号。");
return;
}
if (userStore.existsByUsername(username)) {
return;
}
UserAccount admin = createAccountInternal(username, password);
admin.setAdmin(true);
userStore.save(admin);
logger.info("已创建初始管理员账号: {}", username);
}
// ==================== 注册 ====================
public UserAccount register(String username, String password) {
@@ -327,7 +306,6 @@ public class AccountService {
claims.put("typ", PrincipalType.USER.name());
claims.put("sid", sessionId);
claims.put("name", account.getUsername());
claims.put("adm", account.isAdmin());
return jwtService.issue(account.getUserId(), JwtService.PURPOSE_ACCESS,
properties.getJwt().getAccessTokenTtlSeconds(),
account.getTokenVersion(), claims);
@@ -406,7 +384,7 @@ public class AccountService {
String signalDeviceId = "ctl_" + sessionId;
return new AuthPrincipal(PrincipalType.USER, userId, signalDeviceId,
DeviceType.CONTROLLER, sessionId, account.getUsername(), account.isAdmin());
DeviceType.CONTROLLER, sessionId, account.getUsername());
}
// ==================== 登出 / 封禁 / 踢线 ====================
@@ -507,6 +485,54 @@ public class AccountService {
AuditService.RESULT_SUCCESS, null, "密码修改成功,已吊销全部会话");
}
// ==================== 管理端账号维护 ====================
/**
* 管理员直接创建账号,绕过“是否开放注册”开关。
*
* <p>注意:此处的账号是普通用户(写入 app_user与管理员写入 admin_account完全隔离。
* 若需创建后台管理员,请使用 AdminService而非此接口。
*/
public UserAccount adminCreateUser(String username, String password) {
validateUsername(username);
validatePassword(password, username);
UserAccount account = createAccountInternal(username, password);
auditService.record("ADMIN", null, AuditService.ACTION_USER_CREATED, account.getUserId(),
AuditService.RESULT_SUCCESS, null,
"管理员创建账号: " + username);
return account;
}
/**
* 删除账号,同时吊销其全部会话。
*/
public void deleteUser(String userId) {
UserAccount account = requireUser(userId);
revokeAllSessions(userId, "账号已被删除");
sessionStore.deleteByPrincipal(userId);
userStore.delete(userId);
logger.info("账号 {} ({}) 已被删除", account.getUsername(), userId);
auditService.record("ADMIN", null, AuditService.ACTION_USER_DELETED, userId,
AuditService.RESULT_SUCCESS, null, "删除账号: " + account.getUsername());
}
/**
* 管理员重置密码:无需原密码,成功后强制该账号全部会话下线。
*/
public void adminResetPassword(String userId, String newPassword) {
UserAccount account = requireUser(userId);
validatePassword(newPassword, account.getUsername());
account.pushPasswordHistory(account.getPasswordHash());
account.setPasswordHash(passwordEncoder.encode(newPassword));
account.setPasswordChangedAt(Instant.now());
account.setFailedAttempts(0);
account.setLockedUntil(null);
userStore.save(account);
revokeAllSessions(userId, "密码已被管理员重置,请重新登录");
auditService.record("ADMIN", null, AuditService.ACTION_PASSWORD_CHANGED, userId,
AuditService.RESULT_SUCCESS, null, "管理员重置密码: " + account.getUsername());
}
// ==================== TOTP 双因子 ====================
/**

View File

@@ -0,0 +1,255 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AdminAccount;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.JwtService;
import com.ttstd.signaling.security.SecurityProperties;
import com.ttstd.signaling.security.TokenUtils;
import com.ttstd.signaling.store.AdminStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Pattern;
/**
* 后台管理员服务:与 {@link AccountService}(主控端用户)完全独立。
*
* <ul>
* <li>使用单独的 {@link AdminStore} 与数据库表 admin_account</li>
* <li>登录签发独立用途admin的 JWT前缀为 {@code admt_}</li>
* <li>不共享用户的注册/绑定/设备逻辑。</li>
* </ul>
*/
@Service
public class AdminService {
private static final Logger logger = LoggerFactory.getLogger(AdminService.class);
/** 管理员令牌用途标识,与用户 access/refresh/device 令牌隔离。 */
public static final String TOKEN_PURPOSE = "admin";
/** 令牌前缀,便于前端区分与排查。 */
public static final String TOKEN_PREFIX = "admt_";
private static final Pattern ADMIN_USERNAME_PATTERN =
Pattern.compile("^[A-Za-z0-9_.\\-]{3,32}$");
private final AdminStore adminStore;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final SecurityProperties properties;
@Autowired
public AdminService(AdminStore adminStore,
PasswordEncoder passwordEncoder,
JwtService jwtService,
SecurityProperties properties) {
this.adminStore = adminStore;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.properties = properties;
}
/**
* 启动引导:若管理员表为空,则依据 application.yml 的
* security.admin.bootstrap-username / bootstrap-password 创建初始管理员。
* 若未配置,则记录告警日志(不会抛异常,避免阻断启动)。
*/
@jakarta.annotation.PostConstruct
void bootstrap() {
String username = properties.getAdmin() != null
? properties.getAdmin().getBootstrapUsername() : null;
String password = properties.getAdmin() != null
? properties.getAdmin().getBootstrapPassword() : null;
if (username == null || username.isBlank() || password == null || password.isBlank()) {
if (adminStore.count() == 0) {
logger.warn("后台管理员表为空,且未配置 security.admin.bootstrap-username / "
+ "bootstrap-password。请通过管理接口或环境变量创建首个管理员否则无法登录后台。");
}
return;
}
AdminAccount existing = adminStore.findByUsername(username).orElse(null);
if (existing != null) {
// 已存在同名管理员:保证其密码与配置的引导密码一致,
// 避免早期手动创建的账号与配置不一致导致网页端无法登录。
if (!passwordEncoder.matches(password, existing.getPasswordHash())) {
existing.setPasswordHash(passwordEncoder.encode(password));
existing.setUpdatedAt(java.time.Instant.now());
adminStore.save(existing);
logger.info("已将引导管理员 {} 的密码重置为 application.yml 配置值", username);
}
return;
}
if (adminStore.count() > 0) {
return;
}
createAdminInternal(username, password, username);
logger.info("已根据 application.yml 创建初始后台管理员: {}", username);
}
// ==================== 登录 ====================
public Map<String, Object> login(String username, String password) {
if (username == null || password == null) {
throw AuthException.badRequest("用户名或密码缺失");
}
AdminAccount admin = adminStore.findByUsername(username)
.orElseThrow(() -> AuthException.unauthorized("用户名或密码错误"));
if (admin.isDisabled()) {
throw AuthException.forbidden("该管理员已被禁用", "admin disabled");
}
if (!passwordEncoder.matches(password, admin.getPasswordHash())) {
throw AuthException.unauthorized("用户名或密码错误");
}
admin.setLastLoginAt(Instant.now());
adminStore.save(admin);
return issueToken(admin);
}
/** 校验管理员令牌,返回声明。令牌需以 {@code admt_} 前缀。 */
public Map<String, Object> verifyToken(String rawToken) {
String token = stripPrefix(rawToken);
Map<String, Object> claims = jwtService.verify(token, TOKEN_PURPOSE);
String adminId = JwtService.claimAsString(claims, "sub");
AdminAccount admin = adminStore.findById(adminId)
.orElseThrow(() -> AuthException.unauthorized("管理员不存在或已被删除"));
if (admin.isDisabled()) {
throw AuthException.forbidden("该管理员已被禁用", "admin disabled");
}
return claims;
}
public String currentAdminId(String rawToken) {
Map<String, Object> claims = verifyToken(rawToken);
return JwtService.claimAsString(claims, "sub");
}
private Map<String, Object> issueToken(AdminAccount admin) {
long ttl = properties.getAdmin() != null
? properties.getAdmin().getTokenTtlSeconds() : 3600L;
if (ttl <= 0) {
ttl = 3600L;
}
String jwt = jwtService.issue(admin.getAdminId(), TOKEN_PURPOSE, ttl, 0, null);
Map<String, Object> result = new java.util.HashMap<>();
result.put("token", TOKEN_PREFIX + jwt);
result.put("tokenType", "Bearer");
result.put("expiresIn", ttl);
result.put("adminId", admin.getAdminId());
result.put("username", admin.getUsername());
result.put("displayName", admin.getDisplayName());
return result;
}
// ==================== 管理员管理 ====================
public List<AdminAccount> listAdmins() {
return adminStore.findAll();
}
public AdminAccount createAdmin(String username, String password, String displayName) {
validateUsername(username);
validatePassword(password);
if (adminStore.existsByUsername(username)) {
throw AuthException.badRequest("管理员用户名已被占用");
}
return createAdminInternal(username, password, displayName);
}
private AdminAccount createAdminInternal(String username, String password, String displayName) {
String adminId = "adm_" + TokenUtils.randomBase62(20);
AdminAccount admin = new AdminAccount(adminId, username,
passwordEncoder.encode(password));
admin.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
admin.setStatus(AccountStatus.ACTIVE);
admin.setCreatedAt(Instant.now());
adminStore.save(admin);
logger.info("后台管理员已创建: {} ({})", username, adminId);
return admin;
}
public void disableAdmin(String adminId) {
guardNotLastAdmin(adminId);
AdminAccount admin = requireAdmin(adminId);
admin.setStatus(AccountStatus.DISABLED);
admin.setUpdatedAt(Instant.now());
adminStore.save(admin);
logger.info("后台管理员已禁用: {}", adminId);
}
public void enableAdmin(String adminId) {
AdminAccount admin = requireAdmin(adminId);
admin.setStatus(AccountStatus.ACTIVE);
admin.setUpdatedAt(Instant.now());
adminStore.save(admin);
logger.info("后台管理员已启用: {}", adminId);
}
public void deleteAdmin(String adminId) {
guardNotLastAdmin(adminId);
requireAdmin(adminId);
adminStore.delete(adminId);
logger.info("后台管理员已删除: {}", adminId);
}
public void resetPassword(String adminId, String newPassword) {
validatePassword(newPassword);
AdminAccount admin = requireAdmin(adminId);
admin.setPasswordHash(passwordEncoder.encode(newPassword));
admin.setUpdatedAt(Instant.now());
adminStore.save(admin);
logger.info("后台管理员密码已重置: {}", adminId);
}
private AdminAccount requireAdmin(String adminId) {
return adminStore.findById(adminId)
.orElseThrow(() -> AuthException.badRequest("管理员不存在"));
}
private void guardNotLastAdmin(String adminId) {
long active = adminStore.findAll().stream()
.filter(a -> !a.isDisabled())
.count();
AdminAccount target = adminStore.findById(adminId).orElse(null);
if (target != null && !target.isDisabled() && active <= 1) {
throw AuthException.forbidden("至少需保留一名启用状态的管理员", "last admin");
}
}
private void validateUsername(String username) {
if (username == null || !ADMIN_USERNAME_PATTERN.matcher(username).matches()) {
throw AuthException.badRequest("管理员用户名需为 3-32 位字母、数字、下划线、点或连字符");
}
}
private void validatePassword(String password) {
if (password == null || password.length() < 8 || password.length() > 128) {
throw AuthException.badRequest("密码长度需为 8-128 位");
}
int classes = 0;
if (password.matches(".*[a-z].*")) classes++;
if (password.matches(".*[A-Z].*")) classes++;
if (password.matches(".*\\d.*")) classes++;
if (password.matches(".*[^a-zA-Z0-9].*")) classes++;
if (classes < 2) {
throw AuthException.badRequest("密码需至少包含大小写字母、数字、符号中的两类");
}
}
private static String stripPrefix(String rawToken) {
if (rawToken == null) {
return null;
}
if (rawToken.startsWith(TOKEN_PREFIX)) {
return rawToken.substring(TOKEN_PREFIX.length());
}
return rawToken;
}
}

View File

@@ -1,30 +1,31 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.AuditLog;
import com.ttstd.signaling.repository.AuditLogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicLong;
/**
* 安全审计日志。
*
* <p>记录登录、激活、封禁、踢线等安全敏感操作,用于事后追溯。
* 当前为内存环形缓冲实现(保留最近 N 条),同时写入 SLF4J 便于落盘采集
* 后续可替换为写入 {@code audit_log} 表。
* 记录持久化到 {@code audit_log} 表,同时写入 SLF4J 便于日志采集
*
* <p>写入前会对敏感信息做脱敏,避免日志泄露凭据。
* <p>写入前会对敏感信息做脱敏与长度截断,避免日志泄露凭据。
*/
@Service
public class AuditService {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
private static final int MAX_ENTRIES = 5000;
private static final Logger logger = LoggerFactory.getLogger(AuditService.class);
/** 审计动作常量 */
public static final String ACTION_LOGIN = "LOGIN";
@@ -41,14 +42,21 @@ public class AuditService {
public static final String ACTION_BAN = "BAN";
public static final String ACTION_UNBAN = "UNBAN";
public static final String ACTION_KICK = "KICK";
public static final String ACTION_USER_CREATED = "USER_CREATED";
public static final String ACTION_USER_DELETED = "USER_DELETED";
public static final String ACTION_PROVISION = "DEVICE_PROVISION";
public static final String ACTION_DEVICE_TOKEN = "DEVICE_TOKEN";
public static final String ACTION_DEVICE_DISABLED = "DEVICE_DISABLED";
public static final String ACTION_DEVICE_ENABLED = "DEVICE_ENABLED";
public static final String ACTION_DEVICE_CREATED = "DEVICE_CREATED";
public static final String ACTION_DEVICE_DELETED = "DEVICE_DELETED";
public static final String ACTION_DEVICE_KICK = "DEVICE_KICK";
public static final String ACTION_BIND = "DEVICE_BIND";
public static final String ACTION_UNBIND = "DEVICE_UNBIND";
public static final String ACTION_BLACKLIST_ADD = "DEVICE_BLACKLIST_ADD";
public static final String ACTION_BLACKLIST_REMOVE = "DEVICE_BLACKLIST_REMOVE";
public static final String ACTION_ALLOWLIST_ADD = "DEVICE_ALLOWLIST_ADD";
public static final String ACTION_ALLOWLIST_REMOVE = "DEVICE_ALLOWLIST_REMOVE";
public static final String ACTION_OFFER_BLOCKED = "OFFER_BLOCKED";
public static final String ACTION_PAIRING_GENERATED = "PAIRING_GENERATED";
public static final String ACTION_PAIRING_REDEEMED = "PAIRING_REDEEMED";
@@ -57,7 +65,7 @@ public class AuditService {
public static final String RESULT_FAILURE = "FAILURE";
/**
* 单条审计记录。
* 单条审计记录的对外视图
*/
public record AuditEntry(
long id,
@@ -69,27 +77,55 @@ public class AuditService {
String ip,
String detail,
long timestamp) {
static AuditEntry from(AuditLog log) {
return new AuditEntry(
log.getId() == null ? 0L : log.getId(),
log.getActorType(),
log.getActorId(),
log.getAction(),
log.getTargetId(),
log.getResult(),
log.getIp(),
log.getDetail(),
log.getCreatedAt().toEpochMilli());
}
}
private final Deque<AuditEntry> entries = new ConcurrentLinkedDeque<>();
private final AtomicLong sequence = new AtomicLong();
private final AuditLogRepository repository;
/** 带持久化的标准构造(由 Spring 注入)。 */
public AuditService(AuditLogRepository repository) {
this.repository = repository;
}
/**
* 无参构造:用于不依赖数据库的单元测试。此时审计仅写入 SLF4J不落库。
* 与线上"写库失败仅记日志"的容错策略保持一致。
*/
public AuditService() {
this.repository = null;
}
/**
* 写入一条审计记录。
*
* <p>使用独立事务,确保调用方业务回滚时审计记录仍然保留;
* 审计写库失败不得影响主流程,因此异常仅记录日志。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String actorType, String actorId, String action,
String targetId, String result, String ip, String detail) {
AuditEntry entry = new AuditEntry(
sequence.incrementAndGet(),
actorType, actorId, action, targetId, result, ip,
truncate(detail),
Instant.now().toEpochMilli());
entries.addFirst(entry);
// 环形缓冲:超出上限时丢弃最旧记录
while (entries.size() > MAX_ENTRIES) {
entries.pollLast();
}
String safeDetail = truncate(detail);
auditLogger.info("actor={}:{} action={} target={} result={} ip={} detail={}",
actorType, actorId, action, targetId, result, ip, entry.detail());
actorType, actorId, action, targetId, result, ip, safeDetail);
try {
if (repository != null) {
repository.save(new AuditLog(actorType, actorId, action, targetId, result, ip, safeDetail));
}
} catch (RuntimeException e) {
logger.warn("审计日志写入失败: action={} actor={} 原因={}", action, actorId, e.toString());
}
}
public void recordUser(String userId, String action, String result, String ip, String detail) {
@@ -105,29 +141,39 @@ public class AuditService {
}
/** 查询最近的审计记录。 */
@Transactional(readOnly = true)
public List<AuditEntry> recent(int limit) {
List<AuditEntry> result = new ArrayList<>(Math.min(limit, entries.size()));
for (AuditEntry entry : entries) {
if (result.size() >= limit) {
break;
}
result.add(entry);
}
return result;
return search(null, null, null, 0, limit).getContent();
}
/** 按主体过滤审计记录。 */
@Transactional(readOnly = true)
public List<AuditEntry> byActor(String actorId, int limit) {
List<AuditEntry> result = new ArrayList<>();
for (AuditEntry entry : entries) {
if (result.size() >= limit) {
break;
}
if (actorId.equals(entry.actorId())) {
result.add(entry);
}
return search(actorId, null, null, 0, limit).getContent();
}
/**
* 条件分页查询,空白参数视为不过滤。
*/
@Transactional(readOnly = true)
public Page<AuditEntry> search(String actorId, String action, String result, int page, int size) {
if (repository == null) {
return org.springframework.data.domain.Page.empty();
}
return result;
Page<AuditLog> found = repository.search(
blankToNull(actorId), blankToNull(action), blankToNull(result),
PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), 1000)));
return found.map(AuditEntry::from);
}
/** 清理早于指定时间的历史审计日志。 */
@Transactional
public int purgeBefore(Instant before) {
return repository.deleteOlderThan(before);
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static String truncate(String detail) {

View File

@@ -183,7 +183,7 @@ public class DeviceIdentityService {
deviceStore.save(device);
return new AuthPrincipal(PrincipalType.DEVICE, deviceUid, deviceUid,
DeviceType.CONTROLLED, null, device.getModel(), false);
DeviceType.CONTROLLED, null, device.getModel());
}
private void assertDeviceUsable(DeviceAccount device) {
@@ -223,6 +223,29 @@ public class DeviceIdentityService {
logger.info("设备 {} 已启用", deviceUid);
}
/**
* 强制设备下线:递增令牌版本使现有令牌立即失效,并断开在线连接。
*
* <p>与 {@link #disable} 的区别是不改变账号状态,设备可用新令牌重新上线。
*/
public void kick(String deviceUid, String reason) {
DeviceAccount device = requireDevice(deviceUid);
device.setTokenVersion(device.getTokenVersion() + 1);
deviceStore.save(device);
notifyRevoked(deviceUid, reason == null ? "设备已被强制下线" : reason);
logger.info("设备 {} 已被强制下线:{}", deviceUid, reason);
}
/**
* 删除设备记录,并断开其在线连接。
*/
public void delete(String deviceUid) {
DeviceAccount device = requireDevice(deviceUid);
deviceStore.delete(deviceUid);
notifyRevoked(deviceUid, "设备已被删除");
logger.info("设备 {} (sn={}) 已被删除", deviceUid, device.maskedSn());
}
public DeviceAccount requireDevice(String deviceUid) {
return deviceStore.findByDeviceUid(deviceUid)
.orElseThrow(() -> AuthException.badRequest("设备不存在"));
@@ -258,6 +281,20 @@ public class DeviceIdentityService {
return Set.copyOf(deviceStore.listAllowed().stream().map(DeviceAllowlist::getSn).toList());
}
/** 从 SN 白名单移除,返回是否实际删除。 */
public boolean removeFromAllowlist(String sn) {
if (sn == null || sn.isBlank()) {
throw AuthException.badRequest("SN 不能为空");
}
String trimmed = sn.trim();
if (!deviceStore.isSnAllowed(trimmed)) {
return false;
}
deviceStore.removeAllowedSn(trimmed);
logger.info("SN 已移出白名单: {}", DeviceAccount.maskSn(trimmed));
return true;
}
private void notifyRevoked(String deviceUid, String reason) {
AccountService.SessionRevocationListener listener = this.revocationListener;
if (listener != null) {

View File

@@ -0,0 +1,28 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.AdminAccount;
import java.util.List;
import java.util.Optional;
/**
* 后台管理员存储抽象。与 {@code UserStore} 互不耦合JPA 与内存两套实现可切换。
*/
public interface AdminStore {
AdminAccount save(AdminAccount admin);
Optional<AdminAccount> findById(String adminId);
Optional<AdminAccount> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsById(String adminId);
List<AdminAccount> findAll();
long count();
void delete(String adminId);
}

View File

@@ -16,7 +16,13 @@ public interface DeviceStore {
DeviceAccount save(DeviceAccount account);
List<DeviceAccount> listAll();
/** 删除设备。设备不存在时静默返回。 */
void delete(String deviceUid);
boolean isSnAllowed(String sn);
void addAllowedSn(DeviceAllowlist entry);
List<DeviceAllowlist> listAllowed();
/** 从 SN 白名单移除条目。 */
void removeAllowedSn(String sn);
}

View File

@@ -0,0 +1,74 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.AdminAccount;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Collectors;
/** 内存实现不依赖数据库用于测试与本地演示storage.mode=memory。 */
@Component
@Profile("memory")
public class InMemoryAdminStore implements AdminStore {
private final ConcurrentMap<String, AdminAccount> byId = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> usernameToId = new ConcurrentHashMap<>();
@Override
public synchronized AdminAccount save(AdminAccount admin) {
byId.put(admin.getAdminId(), admin);
if (admin.getUsername() != null) {
usernameToId.put(admin.getUsername().toLowerCase(java.util.Locale.ROOT), admin.getAdminId());
}
return admin;
}
@Override
public Optional<AdminAccount> findById(String adminId) {
return Optional.ofNullable(byId.get(adminId));
}
@Override
public Optional<AdminAccount> findByUsername(String username) {
if (username == null) {
return Optional.empty();
}
String id = usernameToId.get(username.toLowerCase(java.util.Locale.ROOT));
return Optional.ofNullable(id == null ? null : byId.get(id));
}
@Override
public boolean existsByUsername(String username) {
if (username == null) {
return false;
}
return usernameToId.containsKey(username.toLowerCase(java.util.Locale.ROOT));
}
@Override
public boolean existsById(String adminId) {
return byId.containsKey(adminId);
}
@Override
public List<AdminAccount> findAll() {
return List.copyOf(byId.values());
}
@Override
public long count() {
return byId.size();
}
@Override
public synchronized void delete(String adminId) {
AdminAccount removed = byId.remove(adminId);
if (removed != null && removed.getUsername() != null) {
usernameToId.remove(removed.getUsername().toLowerCase(java.util.Locale.ROOT));
}
}
}

View File

@@ -48,6 +48,14 @@ public class InMemoryDeviceStore implements DeviceStore {
return List.copyOf(byUid.values());
}
@Override
public synchronized void delete(String deviceUid) {
DeviceAccount removed = byUid.remove(deviceUid);
if (removed != null) {
snToUid.remove(removed.getSn());
}
}
@Override
public synchronized boolean isSnAllowed(String sn) {
return allowlist.containsKey(sn);
@@ -62,4 +70,9 @@ public class InMemoryDeviceStore implements DeviceStore {
public synchronized List<DeviceAllowlist> listAllowed() {
return List.copyOf(allowlist.values());
}
@Override
public synchronized void removeAllowedSn(String sn) {
allowlist.remove(sn);
}
}

View File

@@ -45,4 +45,12 @@ public class InMemoryUserStore implements UserStore {
public synchronized List<UserAccount> listAll() {
return List.copyOf(byId.values());
}
@Override
public synchronized void delete(String userId) {
UserAccount removed = byId.remove(userId);
if (removed != null) {
usernameToId.remove(removed.getUsername());
}
}
}

View File

@@ -0,0 +1,61 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.AdminAccount;
import com.ttstd.signaling.repository.AdminAccountRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/** JPA 实现:管理员账号持久化到 MySQL。仅在 storage.mode=jpa默认时生效。 */
@Component
@Profile("!memory")
public class JpaAdminStore implements AdminStore {
private final AdminAccountRepository repository;
public JpaAdminStore(AdminAccountRepository repository) {
this.repository = repository;
}
@Override
public AdminAccount save(AdminAccount admin) {
return repository.save(admin);
}
@Override
public Optional<AdminAccount> findById(String adminId) {
return repository.findById(adminId);
}
@Override
public Optional<AdminAccount> findByUsername(String username) {
return repository.findByUsername(username);
}
@Override
public boolean existsByUsername(String username) {
return repository.existsByUsername(username);
}
@Override
public boolean existsById(String adminId) {
return repository.existsById(adminId);
}
@Override
public List<AdminAccount> findAll() {
return repository.findAll();
}
@Override
public long count() {
return repository.count();
}
@Override
public void delete(String adminId) {
repository.deleteById(adminId);
}
}

View File

@@ -49,6 +49,11 @@ public class JpaDeviceStore implements DeviceStore {
return deviceRepository.findAll();
}
@Override
public void delete(String deviceUid) {
deviceRepository.deleteById(deviceUid);
}
@Override
public boolean isSnAllowed(String sn) {
return allowlistRepository.existsBySn(sn);
@@ -63,4 +68,9 @@ public class JpaDeviceStore implements DeviceStore {
public List<DeviceAllowlist> listAllowed() {
return allowlistRepository.findAll();
}
@Override
public void removeAllowedSn(String sn) {
allowlistRepository.deleteById(sn);
}
}

View File

@@ -43,4 +43,9 @@ public class JpaUserStore implements UserStore {
public List<UserAccount> listAll() {
return repository.findAll();
}
@Override
public void delete(String userId) {
repository.deleteById(userId);
}
}

View File

@@ -14,4 +14,7 @@ public interface UserStore {
boolean existsByUsername(String username);
UserAccount save(UserAccount account);
List<UserAccount> listAll();
/** 删除账号。账号不存在时静默返回。 */
void delete(String userId);
}

View File

@@ -34,10 +34,10 @@ spring:
# Redis在线状态、频控计数、nonce 去重、踢线广播
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
host: ${REDIS_HOST:175.178.213.60}
port: ${REDIS_PORT:26379}
password: ${REDIS_PASSWORD:fanhuitong}
database: ${REDIS_DATABASE:15}
timeout: 3000ms
lettuce:
pool:
@@ -52,16 +52,11 @@ redis:
online-key-prefix: "webrtc:online:"
flush-key-prefix: "webrtc:flush:"
# 后台管理接口鉴权令牌;可通过环境变量 ADMIN_TOKEN 覆盖。
# WebRTCSignalServerWeb 登录时使用的默认令牌即为此值。
admin:
token: ${ADMIN_TOKEN:webrtc-admin-token}
security:
jwt:
# 【生产环境必须通过环境变量 JWT_SECRET 覆盖】留空时服务启动会随机生成密钥,
# 随机密钥会导致服务重启后所有已签发 token 失效,且多实例部署无法互认。
secret: ${JWT_SECRET:}
# 开发环境固定默认密钥(至少 32 字节),避免随机密钥导致重启后所有 token 失效。
# 【生产环境必须通过环境变量 JWT_SECRET 覆盖为强随机值】
secret: ${JWT_SECRET:dev-only-fixed-jwt-secret-please-change-32b+}
issuer: webrtc-signal-server
# 访问令牌有效期(秒),默认 15 分钟
access-token-ttl-seconds: 900
@@ -75,7 +70,7 @@ security:
device:
# 设备激活provision共享密钥被控端为系统签名应用内置同一密钥用于 HMAC 签名。
# 【生产环境必须通过环境变量 DEVICE_PROVISION_SECRET 覆盖】
provision-secret: ${DEVICE_PROVISION_SECRET:}
provision-secret: ${DEVICE_PROVISION_SECRET:dev-device-provision-secret-change-me}
# 激活请求时间戳允许的偏移(秒),用于防重放
provision-skew-seconds: 300
# 是否启用 SN 白名单:启用后仅允许已导入白名单的 SN 激活
@@ -89,9 +84,16 @@ security:
lock-duration-seconds: 900
# 单账号最大并发会话数,超出时踢掉最旧会话
max-concurrent-sessions: 5
# 首次启动时自动创建的初始管理员账号(仅当账号不存在时创建)
bootstrap-username: ${BOOTSTRAP_ADMIN_USERNAME:admin}
bootstrap-password: ${BOOTSTRAP_ADMIN_PASSWORD:}
# 后台管理员配置(独立于普通用户体系,使用单独数据库表 admin_account
admin:
# 初始管理员引导:当管理员表为空时,按以下用户名/密码自动创建首个管理员。
# 可通过环境变量 ADMIN_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD 覆盖。
# 若两者均留空,则不自动创建,需通过管理接口建立首个管理员。
bootstrap-username: ${ADMIN_BOOTSTRAP_USERNAME:admin}
bootstrap-password: ${ADMIN_BOOTSTRAP_PASSWORD:admin123456}
# 管理员令牌有效期(秒),默认 1 小时
token-ttl-seconds: 3600
websocket:
# WebSocket 允许的来源,逗号分隔;生产环境应收敛为具体域名