feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -1,10 +1,13 @@
package com.ttstd.signaling.config;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.security.TokenUtils;
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.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -12,12 +15,21 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口
* 校验请求头 {@code X-Admin-Token} 是否与配置的令牌一致。
* 令牌可通过 {@code admin.token} 配置项或环境变量 {@code ADMIN_TOKEN} 设置。
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口
*
* <p>支持两种凭据,满足其一即可通过:
* <ol>
* <li>请求头 {@code X-Admin-Token} 与配置令牌一致(兼容既有管理后台登录方式)</li>
* <li>{@code Authorization: Bearer <token>} 且账号具备管理员角色
* (由 {@link BearerAuthFilter} 预先解析)</li>
* </ol>
*
* <p>过滤器顺序在 {@link BearerAuthFilter} 之后,以便读取其解析出的主体。
*/
@Component
@Order(2)
public class AdminAuthFilter extends OncePerRequestFilter {
@Value("${admin.token:webrtc-admin-token}")
private String adminToken;
@@ -34,13 +46,25 @@ public class AdminAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
String token = request.getHeader("X-Admin-Token");
if (adminToken != null && !adminToken.isBlank() && adminToken.equals(token)) {
if (hasValidAdminToken(request) || hasAdminPrincipal(request)) {
filterChain.doFilter(request, response);
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员令牌\"}");
return;
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员凭据\"}");
}
private boolean hasValidAdminToken(HttpServletRequest request) {
String token = request.getHeader("X-Admin-Token");
return adminToken != null && !adminToken.isBlank()
&& TokenUtils.constantTimeEquals(adminToken, token);
}
private boolean hasAdminPrincipal(HttpServletRequest request) {
Object principal = request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL);
return principal instanceof AuthPrincipal auth && auth.admin();
}
}

View File

@@ -0,0 +1,106 @@
package com.ttstd.signaling.config;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.DeviceIdentityService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
/**
* Bearer 令牌解析过滤器。
*
* <p>解析 {@code Authorization: Bearer <token>} 并将 {@link AuthPrincipal} 写入请求属性,
* 供受保护接口读取。对无需认证的公开端点直接放行;
* 对需要认证但令牌无效的请求返回 401。
*/
@Component
@Order(1)
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/");
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
public BearerAuthFilter(AccountService accountService,
DeviceIdentityService deviceIdentityService) {
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String uri = request.getRequestURI();
if ("OPTIONS".equalsIgnoreCase(request.getMethod()) || PUBLIC_PATHS.contains(uri)) {
filterChain.doFilter(request, response);
return;
}
String token = extractBearer(request);
if (token != null) {
AuthPrincipal principal = resolve(token);
if (principal != null) {
request.setAttribute(ATTR_PRINCIPAL, principal);
}
}
boolean requiresAuth = PROTECTED_PREFIXES.stream().anyMatch(uri::startsWith);
if (requiresAuth && request.getAttribute(ATTR_PRINCIPAL) == null) {
writeUnauthorized(response);
return;
}
filterChain.doFilter(request, response);
}
/** 依次尝试用户令牌与设备令牌。 */
private AuthPrincipal resolve(String token) {
try {
return accountService.authenticate(token);
} catch (AuthException ignored) {
try {
return deviceIdentityService.authenticate(token);
} catch (AuthException ignored2) {
return null;
}
}
}
private String extractBearer(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
String token = authorization.substring(7).trim();
return token.isEmpty() ? null : token;
}
return null;
}
private void writeUnauthorized(HttpServletResponse response) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"认证失败\"}");
}
}

View File

@@ -0,0 +1,15 @@
package com.ttstd.signaling.config;
import com.ttstd.signaling.security.SecurityProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 启用安全相关配置属性绑定与定时任务。
*/
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
@EnableScheduling
public class SecurityBeansConfig {
}

View File

@@ -1,6 +1,9 @@
package com.ttstd.signaling.config;
import com.ttstd.signaling.handler.SignalWebSocketHandler;
import com.ttstd.signaling.security.AuthHandshakeInterceptor;
import com.ttstd.signaling.security.SecurityProperties;
import com.ttstd.signaling.security.SubProtocolHandshakeHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
@@ -8,34 +11,54 @@ import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
import java.util.Arrays;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
private final SignalWebSocketHandler signalWebSocketHandler;
private final AuthHandshakeInterceptor authHandshakeInterceptor;
private final SubProtocolHandshakeHandler handshakeHandler;
private final SecurityProperties securityProperties;
public WebSocketConfig(SignalWebSocketHandler signalWebSocketHandler) {
public WebSocketConfig(SignalWebSocketHandler signalWebSocketHandler,
AuthHandshakeInterceptor authHandshakeInterceptor,
SubProtocolHandshakeHandler handshakeHandler,
SecurityProperties securityProperties) {
this.signalWebSocketHandler = signalWebSocketHandler;
this.authHandshakeInterceptor = authHandshakeInterceptor;
this.handshakeHandler = handshakeHandler;
this.securityProperties = securityProperties;
}
/**
* 配置 WebSocket 容器的消息大小限制与会话超时。
* <p>默认文本消息缓冲区为 8KBWebRTC SDPOFFER/ANSWER消息经 JSON 包装后
* 可能超过该限制,导致服务端抛出 TextMessageLimitException 并关闭连接。
* 此处将文本消息上限设为 512KB二进制消息上限设为 512KB,会话空闲超时设为 10 分钟
* 此处将文本消息上限设为 512KB二进制消息上限设为 512KB。
* <p>空闲超时设为 90 秒,配合客户端 PING 心跳,可尽早回收失联连接,
* 缩短被封禁账号残留连接的存活窗口。
*/
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(512 * 1024); // 512 KB
container.setMaxBinaryMessageBufferSize(512 * 1024); // 512 KB
container.setMaxSessionIdleTimeout(600_000L); // 10 分钟
container.setMaxSessionIdleTimeout(90_000L); // 90
return container;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
String[] origins = Arrays.stream(securityProperties.getWebsocket().getAllowedOrigins().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toArray(String[]::new);
registry.addHandler(signalWebSocketHandler, "/ws/signal")
.setAllowedOrigins("*");
.setHandshakeHandler(handshakeHandler)
.addInterceptors(authHandshakeInterceptor)
.setAllowedOriginPatterns(origins);
}
}

View File

@@ -3,13 +3,27 @@ package com.ttstd.signaling.controller;
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.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.AuditService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -25,14 +39,29 @@ public class AdminController {
private final SessionManager sessionManager;
private final ConnectionRequestManager connectionRequestManager;
private final SignalMetrics metrics;
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
private final AuditService auditService;
private final BindingService bindingService;
private final AbuseReportService abuseReportService;
private final long startTime = System.currentTimeMillis();
public AdminController(SessionManager sessionManager,
ConnectionRequestManager connectionRequestManager,
SignalMetrics metrics) {
SignalMetrics metrics,
AccountService accountService,
DeviceIdentityService deviceIdentityService,
AuditService auditService,
BindingService bindingService,
AbuseReportService abuseReportService) {
this.sessionManager = sessionManager;
this.connectionRequestManager = connectionRequestManager;
this.metrics = metrics;
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
this.auditService = auditService;
this.bindingService = bindingService;
this.abuseReportService = abuseReportService;
}
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
@@ -99,4 +128,310 @@ public class AdminController {
data.put("onlineDevices", sessionManager.getOnlineCount());
return data;
}
// ==================== 账号管理 ====================
/** 主控端账号列表。 */
@GetMapping("/users")
public List<Map<String, Object>> users() {
return accountService.listUsers().stream().map(AdminController::toUserView).toList();
}
/** 指定账号的活跃登录会话。 */
@GetMapping("/users/{userId}/sessions")
public List<Map<String, Object>> userSessions(@PathVariable String userId) {
return accountService.listSessions(userId).stream().map(s -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("sessionId", s.getSessionId());
item.put("ip", s.getIp());
item.put("userAgent", s.getUserAgent());
item.put("createdAt", s.getCreatedAt().toEpochMilli());
item.put("lastSeenAt", s.getLastSeenAt().toEpochMilli());
return item;
}).toList();
}
/**
* 封禁账号并立即下线其全部连接。
*
* <p>请求体:{@code {"reason": "...", "durationSeconds": 3600}}
* durationSeconds 缺省或 <=0 表示永久封禁。
*/
@PostMapping("/users/{userId}/ban")
public Map<String, Object> banUser(@PathVariable String userId,
@RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? null : (String) body.get("reason");
long duration = body == null ? 0 : toLong(body.get("durationSeconds"));
Instant until = duration > 0 ? Instant.now().plusSeconds(duration) : null;
accountService.ban(userId, until, reason);
return Map.of("success", true, "userId", userId,
"permanent", until == null);
}
/** 解封账号。 */
@PostMapping("/users/{userId}/unban")
public Map<String, Object> unbanUser(@PathVariable String userId) {
accountService.unban(userId);
return Map.of("success", true, "userId", userId);
}
/** 强制账号全端下线(不改变封禁状态)。 */
@PostMapping("/users/{userId}/kick")
public Map<String, Object> kickUser(@PathVariable String userId,
@RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? "管理员强制下线" : (String) body.getOrDefault("reason", "管理员强制下线");
accountService.revokeAllSessions(userId, reason);
return Map.of("success", true, "userId", userId);
}
/** 踢出指定登录会话。 */
@PostMapping("/sessions/{sessionId}/kick")
public Map<String, Object> kickSession(@PathVariable String sessionId,
@RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? "管理员强制下线" : (String) body.getOrDefault("reason", "管理员强制下线");
accountService.revokeSession(sessionId, reason);
return Map.of("success", true, "sessionId", sessionId);
}
// ==================== 设备管理 ====================
/** 已激活的被控端设备列表SN 已脱敏)。 */
@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();
}
/** 禁用设备并立即断开其连接。 */
@PostMapping("/device-accounts/{deviceUid}/disable")
public Map<String, Object> disableDevice(@PathVariable String deviceUid,
@RequestBody(required = false) Map<String, Object> body) {
String reason = body == null ? null : (String) body.get("reason");
long duration = body == null ? 0 : toLong(body.get("durationSeconds"));
Instant until = duration > 0 ? Instant.now().plusSeconds(duration) : null;
deviceIdentityService.disable(deviceUid, until, reason);
return Map.of("success", true, "deviceUid", deviceUid);
}
/** 启用设备。 */
@PostMapping("/device-accounts/{deviceUid}/enable")
public Map<String, Object> enableDevice(@PathVariable String deviceUid) {
deviceIdentityService.enable(deviceUid);
return Map.of("success", true, "deviceUid", deviceUid);
}
/** 批量导入 SN 白名单。请求体:{@code {"sns": ["SN001", "SN002"]}} */
@PostMapping("/device-allowlist")
public Map<String, Object> importAllowlist(@RequestBody Map<String, Object> body) {
Object raw = body.get("sns");
if (!(raw instanceof List<?> list)) {
return Map.of("success", false, "message", "缺少 sns 数组");
}
List<String> sns = list.stream().map(String::valueOf).toList();
int added = deviceIdentityService.importAllowlist(sns);
return Map.of("success", true, "added", added,
"total", deviceIdentityService.getAllowlist().size());
}
// ==================== 绑定 / 黑名单管理 ====================
/** 列出某设备的全部绑定关系(含已撤销)。 */
@GetMapping("/devices/{deviceUid}/bindings")
public List<Map<String, Object>> deviceBindings(@PathVariable String deviceUid) {
return bindingService.listByDevice(deviceUid).stream().map(b -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("bindingId", b.getBindingId());
item.put("deviceUid", b.getDeviceUid());
item.put("userId", b.getUserId());
item.put("role", b.getRole().name());
item.put("alias", b.getAlias());
item.put("status", b.getStatus().name());
item.put("boundAt", b.getBoundAt().toEpochMilli());
return item;
}).toList();
}
/** 列出某设备的黑名单。 */
@GetMapping("/devices/{deviceUid}/blacklist")
public List<Map<String, Object>> deviceBlacklist(@PathVariable String deviceUid) {
return bindingService.listBlacklist(deviceUid).stream().map(e -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("blockedUserId", e.getBlockedUserId());
item.put("reason", e.getReason());
item.put("createdAt", e.getCreatedAt().toEpochMilli());
return item;
}).toList();
}
/**
* 管理员建立绑定。
* 请求体:{@code {"username": "alice", "role": "MEMBER", "alias": "客厅电视"}}。
*/
@PostMapping("/devices/{deviceUid}/bind")
public Map<String, Object> adminBind(@PathVariable String deviceUid,
@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
if (username == null || username.isBlank()) {
return Map.of("success", false, "message", "缺少 username");
}
String userId = bindingService.resolveUserId(username);
DeviceBinding.BindingRole role = parseRole(body.get("role"));
String alias = (String) body.get("alias");
DeviceBinding binding = bindingService.bind(deviceUid, userId, role, alias, "admin");
Map<String, Object> result = new LinkedHashMap<>();
result.put("success", true);
result.put("bindingId", binding.getBindingId());
result.put("deviceUid", deviceUid);
result.put("userId", userId);
return result;
}
/** 管理员解绑(按 username。 */
@PostMapping("/devices/{deviceUid}/unbind")
public Map<String, Object> adminUnbind(@PathVariable String deviceUid,
@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
if (username == null || username.isBlank()) {
return Map.of("success", false, "message", "缺少 username");
}
String userId = bindingService.resolveUserId(username);
bindingService.revokeBinding(deviceUid, userId, "admin");
return Map.of("success", true, "deviceUid", deviceUid, "userId", userId);
}
/** 管理员将某账号加入设备黑名单。请求体:{@code {"username": "alice", "reason": "骚扰"}}。 */
@PostMapping("/devices/{deviceUid}/blacklist")
public Map<String, Object> adminBlacklist(@PathVariable String deviceUid,
@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
if (username == null || username.isBlank()) {
return Map.of("success", false, "message", "缺少 username");
}
String userId = bindingService.resolveUserId(username);
String reason = (String) body.get("reason");
bindingService.addBlacklist(deviceUid, userId, reason, "admin");
return Map.of("success", true, "deviceUid", deviceUid, "userId", userId);
}
/** 管理员移除设备黑名单。请求体:{@code {"username": "alice"}}。 */
@PostMapping("/devices/{deviceUid}/unblacklist")
public Map<String, Object> adminUnblacklist(@PathVariable String deviceUid,
@RequestBody Map<String, Object> body) {
String username = (String) body.get("username");
if (username == null || username.isBlank()) {
return Map.of("success", false, "message", "缺少 username");
}
String userId = bindingService.resolveUserId(username);
bindingService.removeBlacklist(deviceUid, userId, "admin");
return Map.of("success", true, "deviceUid", deviceUid, "userId", userId);
}
private static DeviceBinding.BindingRole parseRole(Object value) {
if (value == null) {
return DeviceBinding.BindingRole.MEMBER;
}
try {
return DeviceBinding.BindingRole.valueOf(value.toString().toUpperCase());
} catch (IllegalArgumentException e) {
return DeviceBinding.BindingRole.MEMBER;
}
}
// ==================== 骚扰举报管理 ====================
/** 列出举报。可按状态过滤,亦可按被举报用户过滤。 */
@GetMapping("/abuse-reports")
public List<AbuseReport> listAbuseReports(
@RequestParam(required = false) String status,
@RequestParam(required = false) String reportedUserId) {
if (reportedUserId != null && !reportedUserId.isBlank()) {
return abuseReportService.listByReportedUser(reportedUserId);
}
if (status != null && !status.isBlank()) {
try {
AbuseReport.ReportStatus s = AbuseReport.ReportStatus.valueOf(status.toUpperCase());
return abuseReportService.listByReportedUser(null).stream()
.filter(r -> r.getStatus() == s).toList();
} catch (IllegalArgumentException ignored) {
// 落入默认
}
}
return abuseReportService.listByReportedUser(null);
}
/** 处理举报:标记 HANDLED / DISMISSED并可联动封禁被举报账号。 */
@PostMapping("/abuse-reports/{id}/handle")
public Map<String, Object> handleAbuseReport(@PathVariable Long id,
@RequestBody Map<String, Object> body) {
String statusStr = (String) body.get("status");
AbuseReport.ReportStatus status = AbuseReport.ReportStatus.HANDLED;
if (statusStr != null) {
try {
status = AbuseReport.ReportStatus.valueOf(statusStr.toUpperCase());
} catch (IllegalArgumentException ignored) {
// 保持默认
}
}
abuseReportService.handle(id, status);
Map<String, Object> r = new LinkedHashMap<>();
r.put("success", true);
r.put("id", id);
r.put("status", status.name());
return r;
}
// ==================== 审计日志 ====================
/**
* 查询审计日志。
*
* <p>支持可选过滤:{@code actorId}(账号/设备/管理员 ID
* {@code action}(动作常量,如 LOGIN、BAN、{@code result}SUCCESS/FAILURE
* 默认返回最近 100 条,最多 1000 条。
*/
@GetMapping("/audit")
public List<AuditService.AuditEntry> audit(
@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();
}
private static Map<String, Object> toUserView(UserAccount u) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("userId", u.getUserId());
item.put("username", u.getUsername());
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());
return item;
}
private static long toLong(Object value) {
return value instanceof Number n ? n.longValue() : 0L;
}
}

View File

@@ -0,0 +1,213 @@
package com.ttstd.signaling.controller;
import com.ttstd.signaling.controller.dto.AuthDtos;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.UserAccount;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.RateLimiter;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.TokenPair;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 主控端账号认证接口。
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
/** 登录限流:单 IP 5 分钟内最多 10 次 */
private static final int LOGIN_LIMIT = 10;
private static final long LOGIN_WINDOW_SECONDS = 300;
private final AccountService accountService;
private final RateLimiter rateLimiter;
public AuthController(AccountService accountService, RateLimiter rateLimiter) {
this.accountService = accountService;
this.rateLimiter = rateLimiter;
}
@PostMapping("/register")
public ResponseEntity<Map<String, Object>> register(@Valid @RequestBody AuthDtos.RegisterRequest request,
HttpServletRequest httpRequest) {
String ip = clientIp(httpRequest);
if (!rateLimiter.tryAcquire("register:" + ip, 5, 3600)) {
throw AuthException.tooManyRequests("注册过于频繁,请稍后再试");
}
UserAccount account = accountService.register(request.username(), request.password());
Map<String, Object> body = new LinkedHashMap<>();
body.put("userId", account.getUserId());
body.put("username", account.getUsername());
return ResponseEntity.ok(body);
}
@PostMapping("/login")
public ResponseEntity<AuthDtos.TokenResponse> login(@Valid @RequestBody AuthDtos.LoginRequest request,
HttpServletRequest httpRequest) {
String ip = clientIp(httpRequest);
if (!rateLimiter.tryAcquire("login:" + ip, LOGIN_LIMIT, LOGIN_WINDOW_SECONDS)) {
throw AuthException.tooManyRequests("登录尝试过于频繁,请稍后再试");
}
TokenPair pair = accountService.login(
request.username(), request.password(), request.totpCode(),
ip, userAgent(httpRequest));
rateLimiter.reset("login:" + ip);
return ResponseEntity.ok(toResponse(pair));
}
@PostMapping("/refresh")
public ResponseEntity<AuthDtos.TokenResponse> refresh(@Valid @RequestBody AuthDtos.RefreshRequest request,
HttpServletRequest httpRequest) {
TokenPair pair = accountService.refresh(
request.refreshToken(), clientIp(httpRequest), userAgent(httpRequest));
return ResponseEntity.ok(toResponse(pair));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
accountService.logout(principal.sessionId());
return ResponseEntity.ok(Map.of("success", true));
}
@PostMapping("/logout-all")
public ResponseEntity<Map<String, Object>> logoutAll(HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
accountService.revokeAllSessions(principal.principalId(), "用户主动退出全部设备");
return ResponseEntity.ok(Map.of("success", true));
}
@PostMapping("/change-password")
public ResponseEntity<Map<String, Object>> changePassword(
@Valid @RequestBody AuthDtos.ChangePasswordRequest request,
HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
accountService.changePassword(principal.principalId(),
request.oldPassword(), request.newPassword());
return ResponseEntity.ok(Map.of("success", true, "message", "密码已修改,请重新登录"));
}
// ==================== TOTP 双因子 ====================
/**
* 生成 TOTP 密钥。返回 otpauth URI 供客户端渲染二维码;
* 此时尚未生效,需再调用 /totp/enable 完成绑定。
*/
@PostMapping("/totp/setup")
public ResponseEntity<Map<String, Object>> totpSetup(HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
Map<String, String> result = accountService.setupTotp(principal.principalId());
Map<String, Object> body = new LinkedHashMap<>();
body.put("secret", result.get("secret"));
body.put("otpauthUri", result.get("otpauthUri"));
body.put("notice", "请用认证器扫码后调用 /api/auth/totp/enable 提交动态码完成绑定");
return ResponseEntity.ok(body);
}
/** 提交一次动态码,正式启用双因子。 */
@PostMapping("/totp/enable")
public ResponseEntity<Map<String, Object>> totpEnable(
@Valid @RequestBody AuthDtos.TotpEnableRequest request,
HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
accountService.enableTotp(principal.principalId(), request.code());
return ResponseEntity.ok(Map.of("success", true, "message", "双因子认证已启用"));
}
/** 关闭双因子,需同时校验密码与动态码。 */
@PostMapping("/totp/disable")
public ResponseEntity<Map<String, Object>> totpDisable(
@Valid @RequestBody AuthDtos.TotpDisableRequest request,
HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
accountService.disableTotp(principal.principalId(), request.password(), request.code());
return ResponseEntity.ok(Map.of("success", true, "message", "双因子认证已关闭"));
}
/** 查询当前登录身份。 */
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me(HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
Map<String, Object> body = new LinkedHashMap<>();
body.put("principalId", principal.principalId());
body.put("principalType", principal.principalType().name());
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",
accountService.requireUser(principal.principalId()).isTotpEnabled());
}
return ResponseEntity.ok(body);
}
/** 查询当前账号的活跃会话列表。 */
@GetMapping("/sessions")
public ResponseEntity<List<Map<String, Object>>> sessions(HttpServletRequest httpRequest) {
AuthPrincipal principal = requirePrincipal(httpRequest);
List<Map<String, Object>> body = accountService.listSessions(principal.principalId())
.stream()
.map(s -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("sessionId", s.getSessionId());
item.put("ip", s.getIp());
item.put("userAgent", s.getUserAgent());
item.put("createdAt", s.getCreatedAt().toEpochMilli());
item.put("lastSeenAt", s.getLastSeenAt().toEpochMilli());
item.put("current", s.getSessionId().equals(principal.sessionId()));
return item;
})
.toList();
return ResponseEntity.ok(body);
}
private AuthDtos.TokenResponse toResponse(TokenPair pair) {
return new AuthDtos.TokenResponse(
pair.accessToken(), pair.refreshToken(), pair.expiresInSeconds(),
pair.sessionId(), pair.principalId(), pair.displayName());
}
private AuthPrincipal requirePrincipal(HttpServletRequest request) {
Object principal = request.getAttribute(
com.ttstd.signaling.config.BearerAuthFilter.ATTR_PRINCIPAL);
if (principal instanceof AuthPrincipal auth) {
return auth;
}
throw AuthException.unauthorized("缺少有效的访问令牌");
}
static String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
int comma = forwarded.indexOf(',');
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
return request.getRemoteAddr();
}
static String userAgent(HttpServletRequest request) {
String ua = request.getHeader("User-Agent");
if (ua == null) {
return "unknown";
}
return ua.length() > 256 ? ua.substring(0, 256) : ua;
}
}

View File

@@ -0,0 +1,45 @@
package com.ttstd.signaling.controller;
import com.ttstd.signaling.security.AuthException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 统一异常处理:仅向客户端返回模糊提示,详细原因只记录在服务端日志,
* 避免通过错误信息差异探测账号是否存在、设备是否在线等。
*/
@RestControllerAdvice
public class AuthExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(AuthExceptionHandler.class);
@ExceptionHandler(AuthException.class)
public ResponseEntity<Map<String, Object>> handleAuth(AuthException ex) {
logger.warn("认证/授权失败 [{}]: {}", ex.getCode(), ex.getMessage());
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", ex.getStatus());
body.put("error", ex.getCode());
body.put("message", ex.getPublicMessage());
return ResponseEntity.status(ex.getStatus()).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(e -> e.getDefaultMessage())
.orElse("请求参数不合法");
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", 400);
body.put("error", "BAD_REQUEST");
body.put("message", message);
return ResponseEntity.badRequest().body(body);
}
}

View File

@@ -0,0 +1,368 @@
package com.ttstd.signaling.controller;
import com.ttstd.signaling.config.BearerAuthFilter;
import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.PrincipalType;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.JwtService;
import com.ttstd.signaling.security.SecurityProperties;
import com.ttstd.signaling.service.AbuseReportService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
import com.ttstd.signaling.service.PairingService;
import com.ttstd.signaling.service.TurnCredentialService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 客户端自助接口:供 WebRTCController / WebRTCControlled / Web / Flutter / iOS 五端对接所用。
*
* <p>所有接口均需在请求中携带有效令牌({@code Authorization: Bearer <token>}
* 由 {@link BearerAuthFilter} 统一解析并写入主体信息;令牌缺失或非法将返回 401。
*
* <p>包含:
* <ul>
* <li>{@code /verify} —— 令牌校验(用户/设备令牌通用),返回有效性、过期时间与剩余秒数;
* <li>{@code /device/me} —— 设备自助信息,被控端查看自身状态与在线情况;
* <li>{@code /account/me} —— 账号自助信息,主控端查看自身状态与双因子开关;
* <li>{@code /ws-info} —— 握手指引,返回 WebSocket 地址、子协议与关闭码,便于客户端自配置。
* </ul>
*/
@RestController
@RequestMapping("/api/client")
public class ClientController {
private final JwtService jwtService;
private final SecurityProperties properties;
private final DeviceIdentityService deviceIdentityService;
private final BindingService bindingService;
private final PairingService pairingService;
private final TurnCredentialService turnCredentialService;
private final AbuseReportService abuseReportService;
public ClientController(JwtService jwtService,
SecurityProperties properties,
DeviceIdentityService deviceIdentityService,
BindingService bindingService,
PairingService pairingService,
TurnCredentialService turnCredentialService,
AbuseReportService abuseReportService) {
this.jwtService = jwtService;
this.properties = properties;
this.deviceIdentityService = deviceIdentityService;
this.bindingService = bindingService;
this.pairingService = pairingService;
this.turnCredentialService = turnCredentialService;
this.abuseReportService = abuseReportService;
}
// ==================== 令牌校验 ====================
/**
* 校验调用方自身令牌的有效性。
*
* <p>客户端在建立 WebSocket 长连前可先调用此接口确认令牌未过期/未失效,
* 避免握手阶段被直接断开。支持用户令牌与设备令牌。
*
* @return valid 是否有效;若有效则附带 principalType、principalId、displayName、
* expiresAt秒级时间戳、remainingSeconds剩余有效秒数
*/
@GetMapping("/verify")
public Map<String, Object> verify(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
String token = extractBearer(request);
Map<String, Object> claims;
try {
// 不限定用途:用户 access 与设备 device 令牌均可用于 HTTP 鉴权
claims = jwtService.verify(token, null);
} catch (AuthException e) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("valid", false);
body.put("error", e.getCode());
body.put("message", e.getMessage());
return body;
}
long exp = JwtService.claimAsLong(claims, "exp", 0L);
long now = Instant.now().getEpochSecond();
long remaining = Math.max(0, exp - now);
Map<String, Object> body = new LinkedHashMap<>();
body.put("valid", true);
body.put("principalType", principal.principalType().name());
body.put("principalId", principal.principalId());
body.put("displayName", principal.displayName());
body.put("expiresAt", exp);
body.put("remainingSeconds", remaining);
body.put("serverTime", now);
return body;
}
// ==================== 设备自助信息 ====================
/**
* 被控端查看自身信息(需设备令牌)。
*/
@GetMapping("/device/me")
public Map<String, Object> deviceMe(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可访问此接口");
}
DeviceAccount device = deviceIdentityService.findByUid(principal.principalId());
if (device == null) {
throw AuthException.notFound("设备不存在");
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("deviceUid", device.getDeviceUid());
body.put("model", device.getModel());
body.put("status", device.getStatus().name());
body.put("usable", device.isUsable());
body.put("provisionedAt", device.getProvisionedAt() == null ? null
: device.getProvisionedAt().getEpochSecond());
body.put("lastOnlineAt", device.getLastOnlineAt() == null ? null
: device.getLastOnlineAt().getEpochSecond());
if (device.getStatus() == AccountStatus.SUSPENDED && device.getStatusUntil() != null) {
body.put("statusUntil", device.getStatusUntil().getEpochSecond());
}
body.put("statusReason", device.getStatusReason());
return body;
}
// ==================== 握手指引 ====================
/**
* 返回客户端建立 WebSocket 长连所需的握手参数,便于各端自配置。
*/
@GetMapping("/ws-info")
public Map<String, Object> wsInfo() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("wsPath", "/ws/signal");
body.put("subprotocol", "signal.v1");
body.put("tokenMethods", new String[]{
"header:Authorization Bearer <token>",
"subprotocol:Sec-WebSocket-Protocol: signal.v1, auth.<token>",
"query:?token=<token>"
});
body.put("closeCodes", Map.of(
"NORMAL", 1000,
"UNAUTHORIZED", 4001,
"FORCE_LOGOUT", 4003));
body.put("idleTimeoutSeconds", 90);
body.put("recommendedRefreshRatio", 0.33);
body.put("accessTokenTtlSeconds", properties.getJwt().getAccessTokenTtlSeconds());
body.put("deviceTokenTtlSeconds", properties.getJwt().getDeviceTokenTtlSeconds());
body.put("turnEnabled", turnCredentialService.isEnabled());
return body;
}
// ==================== 配对码(建立绑定) ====================
/**
* 被控端生成一次性配对码(明文仅回显一次)。主控端输入该码即可建立绑定。
*/
@PostMapping("/device/pairing-code")
public Map<String, Object> generatePairingCode(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String code = pairingService.generate(principal.principalId());
Map<String, Object> r = new LinkedHashMap<>();
r.put("code", code);
r.put("expiresInSeconds", 600);
return r;
}
/**
* 主控端兑换配对码,建立与被控端的绑定关系。
*/
@PostMapping("/pairing/redeem")
public Map<String, Object> redeemPairingCode(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isUser()) {
throw AuthException.forbidden("仅用户令牌可调用");
}
String code = body.get("code");
DeviceBinding binding = pairingService.redeem(code, principal.principalId());
return bindingView(binding);
}
// ==================== TURN 短期凭证 ====================
/**
* 获取 TURN 短期凭证ICE servers。主控端与被控端在建立 PeerConnection 前调用。
*/
@GetMapping("/turn-credentials")
public Map<String, Object> turnCredentials(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
return turnCredentialService.issue(principal.principalType() + ":" + principal.principalId());
}
// ==================== 骚扰举报P2 风控) ====================
/**
* 被控端举报某主控端账号骚扰。需提供被举报方用户名。
*/
@PostMapping("/device/report")
public Map<String, Object> reportAbuse(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String username = body.get("username");
if (username == null || username.isBlank()) {
throw AuthException.badRequest("被举报用户名不能为空");
}
String reportedUserId = bindingService.resolveUserId(username);
abuseReportService.report(principal.principalId(), reportedUserId,
body.getOrDefault("reason", ""));
Map<String, Object> r = new LinkedHashMap<>();
r.put("ok", true);
return r;
}
// ==================== 被控端自助管理(设备令牌) ====================
/**
* 被控端将某主控端账号加入绑定(允许其发起连接)。
* 通过用户名解析主控端用户 ID绑定由设备侧创建OWNER 视为设备所有者)。
*/
@PostMapping("/device/bind")
public Map<String, Object> deviceBind(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String username = body.get("username");
String alias = body.get("alias");
String userId = bindingService.resolveUserId(username);
DeviceBinding binding = bindingService.bind(
principal.principalId(), userId, DeviceBinding.BindingRole.MEMBER, alias,
"device:" + principal.principalId());
return bindingView(binding);
}
/** 被控端解除某主控端账号的绑定。 */
@PostMapping("/device/unbind")
public Map<String, Object> deviceUnbind(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String username = body.get("username");
String userId = bindingService.resolveUserId(username);
bindingService.revokeBinding(principal.principalId(), userId, "device:" + principal.principalId());
Map<String, Object> r = new LinkedHashMap<>();
r.put("ok", true);
return r;
}
/** 被控端拉黑某主控端账号优先级高于绑定OFFER 将被服务端拒绝)。 */
@PostMapping("/device/blacklist")
public Map<String, Object> deviceBlacklist(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String username = body.get("username");
String reason = body.get("reason");
String userId = bindingService.resolveUserId(username);
bindingService.addBlacklist(principal.principalId(), userId, reason, "device:" + principal.principalId());
Map<String, Object> r = new LinkedHashMap<>();
r.put("ok", true);
return r;
}
/** 被控端解除对某主控端账号的拉黑。 */
@PostMapping("/device/unblacklist")
public Map<String, Object> deviceUnblacklist(HttpServletRequest request, @RequestBody Map<String, String> body) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
String username = body.get("username");
String userId = bindingService.resolveUserId(username);
bindingService.removeBlacklist(principal.principalId(), userId, "device:" + principal.principalId());
Map<String, Object> r = new LinkedHashMap<>();
r.put("ok", true);
return r;
}
/** 被控端查看自身的绑定与黑名单列表。 */
@GetMapping("/device/relations")
public Map<String, Object> deviceRelations(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isDevice()) {
throw AuthException.forbidden("仅设备令牌可调用");
}
Map<String, Object> r = new LinkedHashMap<>();
r.put("bindings", bindingService.listByDevice(principal.principalId()).stream()
.map(this::bindingView).toList());
r.put("blacklist", bindingService.listBlacklist(principal.principalId()).stream()
.map(e -> Map.of(
"blockedUserId", e.getBlockedUserId(),
"reason", e.getReason() == null ? "" : e.getReason()))
.toList());
return r;
}
// ==================== 主控端自助查询(用户令牌) ====================
/** 主控端查看自己已绑定的设备及其在线状态。 */
@GetMapping("/bindings")
public Map<String, Object> myBindings(HttpServletRequest request) {
AuthPrincipal principal = requirePrincipal(request);
if (!principal.isUser()) {
throw AuthException.forbidden("仅用户令牌可调用");
}
Map<String, Object> r = new LinkedHashMap<>();
r.put("bindings", bindingService.listByUser(principal.principalId()).stream()
.filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE)
.map(this::bindingView).toList());
return r;
}
private Map<String, Object> bindingView(DeviceBinding b) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("bindingId", b.getBindingId());
m.put("deviceUid", b.getDeviceUid());
m.put("userId", b.getUserId());
m.put("role", b.getRole().name());
m.put("alias", b.getAlias());
m.put("status", b.getStatus().name());
return m;
}
// ==================== 工具方法 ====================
private AuthPrincipal requirePrincipal(HttpServletRequest request) {
AuthPrincipal principal = (AuthPrincipal) request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL);
if (principal == null) {
throw AuthException.unauthorized("令牌无效或缺失");
}
return principal;
}
private String extractBearer(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
String token = authorization.substring(7).trim();
return token.isEmpty() ? null : token;
}
return null;
}
}

View File

@@ -0,0 +1,71 @@
package com.ttstd.signaling.controller;
import com.ttstd.signaling.controller.dto.AuthDtos;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.RateLimiter;
import com.ttstd.signaling.service.DeviceIdentityService;
import com.ttstd.signaling.service.TokenPair;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 被控端设备认证接口。
*
* <p>被控端无法使用账号登录改由「SN + 内置共享密钥 HMAC」激活
* 激活后凭 deviceSecret 换取短期访问令牌用于 WebSocket 握手。
*/
@RestController
@RequestMapping("/api/device")
public class DeviceAuthController {
private final DeviceIdentityService deviceIdentityService;
private final RateLimiter rateLimiter;
public DeviceAuthController(DeviceIdentityService deviceIdentityService, RateLimiter rateLimiter) {
this.deviceIdentityService = deviceIdentityService;
this.rateLimiter = rateLimiter;
}
/**
* 设备激活:返回 deviceUid 与 deviceSecretdeviceSecret 仅此一次明文返回)。
*/
@PostMapping("/provision")
public ResponseEntity<AuthDtos.ProvisionResponse> provision(
@Valid @RequestBody AuthDtos.ProvisionRequest request,
HttpServletRequest httpRequest) {
String ip = AuthController.clientIp(httpRequest);
if (!rateLimiter.tryAcquire("provision:" + ip, 10, 3600)) {
throw AuthException.tooManyRequests("激活请求过于频繁,请稍后再试");
}
DeviceIdentityService.ProvisionResult result = deviceIdentityService.provision(
request.sn(), request.model(), request.nonce(), request.timestamp(), request.hmac());
return ResponseEntity.ok(new AuthDtos.ProvisionResponse(
result.deviceUid(),
result.deviceSecret(),
"deviceSecret 仅返回一次,请立即安全存储(建议 Android Keystore"));
}
/**
* 以 deviceUid + deviceSecret 换取短期设备访问令牌。
*/
@PostMapping("/token")
public ResponseEntity<AuthDtos.TokenResponse> token(
@Valid @RequestBody AuthDtos.DeviceTokenRequest request,
HttpServletRequest httpRequest) {
String ip = AuthController.clientIp(httpRequest);
if (!rateLimiter.tryAcquire("devtoken:" + ip, 60, 3600)) {
throw AuthException.tooManyRequests("请求过于频繁,请稍后再试");
}
TokenPair pair = deviceIdentityService.issueDeviceToken(
request.deviceUid(), request.deviceSecret());
return ResponseEntity.ok(new AuthDtos.TokenResponse(
pair.accessToken(), null, pair.expiresInSeconds(),
null, pair.principalId(), pair.displayName()));
}
}

View File

@@ -0,0 +1,80 @@
package com.ttstd.signaling.controller.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 认证相关请求/响应 DTO 集合。
*/
public final class AuthDtos {
private AuthDtos() {
}
// ==================== 主控端账号 ====================
public record RegisterRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password) {
}
/**
* 登录请求。{@code totpCode} 仅在账号启用双因子时必填;
* 未填时服务端返回 error=TOTP_REQUIRED客户端据此引导用户输入动态码。
*/
public record LoginRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password,
String totpCode) {
}
/** 启用 TOTP提交一次动态码完成绑定。 */
public record TotpEnableRequest(
@NotBlank(message = "动态码不能为空") String code) {
}
/** 关闭 TOTP需同时提供密码与动态码。 */
public record TotpDisableRequest(
@NotBlank(message = "密码不能为空") String password,
@NotBlank(message = "动态码不能为空") String code) {
}
public record RefreshRequest(
@NotBlank(message = "刷新令牌不能为空") String refreshToken) {
}
public record ChangePasswordRequest(
@NotBlank(message = "原密码不能为空") String oldPassword,
@NotBlank(message = "新密码不能为空") String newPassword) {
}
/** 登录/刷新响应。设备令牌场景 refreshToken 为 null。 */
public record TokenResponse(
String accessToken,
String refreshToken,
long expiresIn,
String sessionId,
String principalId,
String displayName) {
}
// ==================== 被控端设备 ====================
public record ProvisionRequest(
@NotBlank(message = "SN 不能为空") String sn,
String model,
@NotBlank(message = "nonce 不能为空") String nonce,
long timestamp,
@NotBlank(message = "签名不能为空") String hmac) {
}
public record ProvisionResponse(
String deviceUid,
String deviceSecret,
String notice) {
}
public record DeviceTokenRequest(
@NotBlank(message = "deviceUid 不能为空") String deviceUid,
@NotBlank(message = "deviceSecret 不能为空") String deviceSecret) {
}
}

View File

@@ -4,8 +4,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.ttstd.signaling.manager.ConnectionRequestManager;
import com.ttstd.signaling.manager.SessionManager;
import com.ttstd.signaling.manager.SignalMetrics;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.SignalMessage;
import com.ttstd.signaling.security.AuthHandshakeInterceptor;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.AuditService;
import com.ttstd.signaling.service.BindingService;
import com.ttstd.signaling.service.DeviceIdentityService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@@ -18,6 +26,7 @@ import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Component
public class SignalWebSocketHandler extends TextWebSocketHandler {
@@ -25,40 +34,129 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(SignalWebSocketHandler.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
/** 因鉴权/封禁被关闭连接时使用的关闭码 */
private static final CloseStatus CLOSE_UNAUTHORIZED = new CloseStatus(4001, "UNAUTHORIZED");
private static final CloseStatus CLOSE_FORCED_LOGOUT = new CloseStatus(4003, "FORCED_LOGOUT");
private final SessionManager sessionManager;
private final ConnectionRequestManager connectionRequestManager;
private final SignalMetrics metrics;
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
private final BindingService bindingService;
private final AuditService auditService;
public SignalWebSocketHandler(SessionManager sessionManager,
ConnectionRequestManager connectionRequestManager,
SignalMetrics metrics) {
SignalMetrics metrics,
AccountService accountService,
DeviceIdentityService deviceIdentityService,
BindingService bindingService,
AuditService auditService) {
this.sessionManager = sessionManager;
this.connectionRequestManager = connectionRequestManager;
this.metrics = metrics;
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
this.bindingService = bindingService;
this.auditService = auditService;
this.connectionRequestManager.setSender(this::sendToDevice);
}
/**
* 注册会话失效回调:账号被封禁 / 会话被踢出 / 设备被禁用时立即断开对应连接。
*/
@PostConstruct
void registerRevocationListener() {
AccountService.SessionRevocationListener listener = this::forceDisconnect;
accountService.setRevocationListener(listener);
deviceIdentityService.setRevocationListener(listener);
}
/**
* 强制断开指定主体的连接。
*
* @param principalId 主体 ID
* @param sessionId 指定登录会话;为 null 表示断开该主体全部连接
*/
private void forceDisconnect(String principalId, String sessionId, String reason) {
Set<String> deviceIds = sessionManager.getDeviceIdsByPrincipal(principalId);
for (String deviceId : deviceIds) {
AuthPrincipal principal = sessionManager.getPrincipal(deviceId);
if (principal == null) {
continue;
}
// 指定了会话时只断开该会话,避免误伤同账号其他端
if (sessionId != null && !sessionId.equals(principal.sessionId())) {
continue;
}
WebSocketSession session = sessionManager.getSession(deviceId);
if (session == null || !session.isOpen()) {
continue;
}
Map<String, Object> notice = new HashMap<>();
notice.put("type", "FORCE_LOGOUT");
notice.put("payload", reason == null ? "会话已失效" : reason);
sendToSession(session, notice);
try {
session.close(CLOSE_FORCED_LOGOUT);
} catch (IOException e) {
logger.warn("强制断开连接 {} 失败: {}", deviceId, e.getMessage());
}
logger.info("已强制断开连接: deviceId={} principal={} reason={}",
deviceId, principalId, reason);
}
}
/**
* 向指定设备回送服务端事件(错误/超时通知等)。
*/
private void sendToDevice(String deviceId, Object message) {
WebSocketSession session = sessionManager.getSession(deviceId);
if (session == null || !session.isOpen()) {
logger.warn("Cannot send event to {}: device offline", deviceId);
logger.warn("无法向 {} 发送事件:连接不在线", deviceId);
return;
}
sendToSession(session, message);
}
/**
* 连接建立后立即以握手阶段裁定的身份完成注册,无需客户端再发 REGISTER。
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
logger.info("New WebSocket connection: {}", session.getId());
AuthPrincipal principal = principalOf(session);
if (principal == null) {
// 正常情况下握手拦截器已拦截,此处为纵深防御
logger.warn("连接 {} 缺少鉴权主体,立即关闭", session.getId());
session.close(CLOSE_UNAUTHORIZED);
return;
}
sessionManager.registerDevice(principal, session);
metrics.incRegistered();
metrics.recordSessionCount(sessionManager.getOnlineCount());
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);
logger.info("连接已建立并注册: {} ({})", principal.deviceId(), principal.deviceType());
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
AuthPrincipal principal = principalOf(session);
if (principal == null) {
session.close(CLOSE_UNAUTHORIZED);
return;
}
String payload = message.getPayload();
logger.debug("Received message: {}", payload);
logger.debug("收到消息: {}", payload);
metrics.incMessage();
try {
@@ -66,22 +164,54 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
String type = signalMessage.getType();
if (type == null) {
logger.warn("Message type is null");
logger.warn("消息缺少 type 字段");
return;
}
// 关键:发送方身份一律以服务端鉴权结果覆盖,忽略客户端自报值,
// 防止伪造 fromDeviceId 冒充他人。
String claimedFrom = signalMessage.getFromDeviceId();
if (claimedFrom != null && !claimedFrom.equals(principal.deviceId())) {
logger.warn("客户端自报 fromDeviceId={} 与鉴权身份 {} 不一致,已强制覆盖",
claimedFrom, principal.deviceId());
}
signalMessage.setFromDeviceId(principal.deviceId());
signalMessage.setDeviceType(principal.deviceType().name());
switch (type.toUpperCase()) {
case "REGISTER":
handleRegister(session, signalMessage);
// 身份已在握手阶段确定REGISTER 仅作兼容响应
handleLegacyRegister(session, principal);
break;
case "DEVICE_LIST":
handleDeviceList(session, signalMessage);
handleDeviceList(session, principal);
break;
case "PING":
// 客户端心跳保活消息,无需处理,仅用于防止中间代理因空闲超时断开连接
break;
case "OFFER":
metrics.incOffer();
// 绑定/黑名单前置校验:仅允许已绑定且未被拉黑的主控端发起
if (principal.deviceType() == DeviceType.CONTROLLER) {
String targetUid = signalMessage.getToDeviceId();
if (targetUid == null || targetUid.isEmpty()) {
sendError(session, "缺少目标设备ID");
metrics.incBlockedOffer();
break;
}
if (!bindingService.isBound(targetUid, principal.principalId())) {
sendError(session, "未与该设备建立绑定关系,无法发起连接");
metrics.incBlockedOffer();
auditOfferBlocked(targetUid, principal.principalId(), "NOT_BOUND");
break;
}
if (bindingService.isBlacklisted(targetUid, principal.principalId())) {
sendError(session, "该设备已拒绝来自你的连接");
metrics.incBlockedOffer();
auditOfferBlocked(targetUid, principal.principalId(), "BLACKLISTED");
break;
}
}
// 连接请求:统一经 ConnectionRequestManager 做校验/去重/待确认跟踪后再转发
handleConnectionRequest(signalMessage);
break;
@@ -110,13 +240,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
break;
}
} catch (Exception e) {
logger.error("Error handling message: {}", e.getMessage(), e);
logger.error("处理消息出错: {}", e.getMessage(), e);
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
logger.info("WebSocket connection closed: {} ({})", session.getId(), status);
logger.info("连接已关闭: {} ({})", session.getId(), status);
sessionManager.unregisterSession(session);
metrics.recordSessionCount(sessionManager.getOnlineCount());
}
@@ -131,10 +261,10 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
|| exception.getMessage().contains("Broken pipe")
|| exception.getMessage().contains("An established connection")));
if (benign) {
logger.debug("Transport closed (client disconnected) on session {}: {}",
logger.debug("传输层关闭(客户端断开),会话 {}: {}",
session.getId(), exception.getMessage());
} else {
logger.warn("Transport error on session {}: {}", session.getId(), exception.getMessage());
logger.warn("传输层错误,会话 {}: {}", session.getId(), exception.getMessage());
}
sessionManager.unregisterSession(session);
metrics.recordSessionCount(sessionManager.getOnlineCount());
@@ -144,44 +274,64 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
}
}
private void handleRegister(WebSocketSession session, SignalMessage message) {
String deviceId = message.getFromDeviceId();
String deviceTypeStr = message.getDeviceType();
if (deviceId == null || deviceTypeStr == null) {
logger.warn("Invalid REGISTER message: missing deviceId or deviceType");
return;
}
try {
DeviceType deviceType = DeviceType.valueOf(deviceTypeStr.toUpperCase());
sessionManager.registerDevice(deviceId, deviceType, session);
metrics.incRegistered();
metrics.recordSessionCount(sessionManager.getOnlineCount());
// 回复注册成功
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", deviceId);
sendToSession(session, response);
logger.info("Device {} registered as {}", deviceId, deviceType);
} catch (IllegalArgumentException e) {
logger.warn("Invalid device type: {}", deviceTypeStr);
}
private AuthPrincipal principalOf(WebSocketSession session) {
Object attr = session.getAttributes().get(AuthHandshakeInterceptor.ATTR_PRINCIPAL);
return attr instanceof AuthPrincipal principal ? principal : null;
}
private void handleDeviceList(WebSocketSession session, SignalMessage message) {
List<String> controllers = sessionManager.getDevicesByType(DeviceType.CONTROLLER);
List<String> controlled = sessionManager.getDevicesByType(DeviceType.CONTROLLED);
/**
* 兼容旧客户端的 REGISTER不再接受客户端自报身份仅回显服务端裁定结果。
*/
private void handleLegacyRegister(WebSocketSession session, AuthPrincipal principal) {
Map<String, Object> response = new HashMap<>();
response.put("type", "REGISTER_SUCCESS");
response.put("deviceId", principal.deviceId());
response.put("deviceType", principal.deviceType().name());
response.put("displayName", principal.displayName());
sendToSession(session, response);
}
/**
* 设备列表(绑定视图)。
*
* <p>为防止枚举被控端,服务端不再向任意主控端返回全局在线设备清单。
* 主控端仅能看到「自己已绑定」的设备及其在线状态;被控端此项为空列表。
*/
private void handleDeviceList(WebSocketSession session, AuthPrincipal principal) {
Map<String, Object> response = new HashMap<>();
response.put("type", "DEVICE_LIST");
response.put("controllers", controllers);
response.put("controlled", controlled);
if (principal.deviceType() == DeviceType.CONTROLLER) {
List<Map<String, Object>> devices = bindingService.listByUser(principal.principalId())
.stream()
.filter(b -> b.getStatus() == DeviceBinding.BindingStatus.ACTIVE)
.map(b -> {
Map<String, Object> item = new HashMap<>();
item.put("deviceUid", b.getDeviceUid());
item.put("alias", b.getAlias());
item.put("role", b.getRole().name());
item.put("online", sessionManager.isDeviceOnline(b.getDeviceUid()));
return item;
})
.toList();
response.put("boundDevices", devices);
} else {
// 被控端无需浏览主控端列表
response.put("boundDevices", List.of());
}
sendToSession(session, response);
}
/** 记录被拦截的 OFFER未绑定/被拉黑),便于审计与风控。 */
private void auditOfferBlocked(String deviceUid, String userId, String reason) {
try {
auditService.recordUser(userId, AuditService.ACTION_OFFER_BLOCKED,
AuditService.RESULT_SUCCESS, null, "device=" + deviceUid + " reason=" + reason);
} catch (Exception e) {
logger.warn("审计 OFFER 拦截记录失败: {}", e.getMessage());
}
}
/**
* 处理主控端发来的连接请求OFFER先做设备类型校验与去重
* 再登记“待被控端确认”状态,最后转发给被控端。
@@ -193,7 +343,7 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
// 1. 校验:仅允许 CONTROLLER -> CONTROLLED
String error = connectionRequestManager.validateOffer(fromDeviceId, toDeviceId);
if (error != null) {
logger.warn("Invalid connection request {} -> {}: {}", fromDeviceId, toDeviceId, error);
logger.warn("非法连接请求 {} -> {}: {}", fromDeviceId, toDeviceId, error);
Map<String, Object> response = new HashMap<>();
response.put("type", "REQUEST_ERROR");
response.put("toDeviceId", toDeviceId);
@@ -225,13 +375,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
// 2. 去重:短时间内重复 OFFER 直接忽略,避免被控端反复弹窗
if (connectionRequestManager.isDuplicateOffer(fromDeviceId, toDeviceId)) {
logger.info("Duplicate connection request {} -> {} ignored", fromDeviceId, toDeviceId);
logger.info("重复连接请求 {} -> {} 已忽略", fromDeviceId, toDeviceId);
return;
}
// 3. 目标不在线:立即回送 TARGET_OFFLINE且不登记待确认避免等待超时
if (!sessionManager.isDeviceOnline(toDeviceId)) {
logger.warn("Target device {} is offline, cannot deliver connection request", toDeviceId);
logger.warn("目标被控端 {} 不在线,无法投递连接请求", toDeviceId);
notifySenderTargetOffline(message, toDeviceId);
return;
}
@@ -244,13 +394,13 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
private void forwardMessage(SignalMessage message, boolean notifyOffline) {
String toDeviceId = message.getToDeviceId();
if (toDeviceId == null) {
logger.warn("Cannot forward message: toDeviceId is null");
logger.warn("无法转发消息:toDeviceId 为空");
return;
}
WebSocketSession targetSession = sessionManager.getSession(toDeviceId);
if (targetSession == null || !targetSession.isOpen()) {
logger.warn("Target device {} is not online", toDeviceId);
logger.warn("目标设备 {} 不在线", toDeviceId);
// 仅 OFFER / ANSWER 在目标不在线时回送 TARGET_OFFLINE其余类型静默丢弃
if (notifyOffline) {
notifySenderTargetOffline(message, toDeviceId);
@@ -261,9 +411,9 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
try {
String jsonMessage = objectMapper.writeValueAsString(message);
targetSession.sendMessage(new TextMessage(jsonMessage));
logger.debug("Forwarded {} from {} to {}", message.getType(), message.getFromDeviceId(), toDeviceId);
logger.debug("已转发 {}{} -> {}", message.getType(), message.getFromDeviceId(), toDeviceId);
} catch (IOException e) {
logger.error("Error forwarding message to {}: {}", toDeviceId, e.getMessage());
logger.error("转发消息到 {} 失败: {}", toDeviceId, e.getMessage());
}
}
@@ -273,23 +423,23 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
private void notifySenderTargetOffline(SignalMessage message, String offlineDeviceId) {
String fromDeviceId = message.getFromDeviceId();
if (fromDeviceId == null) {
logger.warn("Cannot notify offline state: fromDeviceId is null");
logger.warn("无法通知离线状态:fromDeviceId 为空");
return;
}
WebSocketSession senderSession = sessionManager.getSession(fromDeviceId);
if (senderSession == null || !senderSession.isOpen()) {
logger.warn("Sender {} session not found, cannot notify target offline", fromDeviceId);
logger.warn("发送方 {} 会话不存在,无法通知目标离线", fromDeviceId);
return;
}
Map<String, Object> response = new HashMap<>();
response.put("type", "TARGET_OFFLINE");
response.put("toDeviceId", offlineDeviceId);
response.put("payload", "目标被控端" + offlineDeviceId + "不在线,请确认设备已开启并连接到信令服务器");
response.put("payload", "目标被控端不在线,请确认设备已开启并连接到信令服务器");
sendToSession(senderSession, response);
logger.info("Notified sender {} that target {} is offline", fromDeviceId, offlineDeviceId);
logger.info("已通知发送方 {} 目标 {} 不在线", fromDeviceId, offlineDeviceId);
}
private void sendToSession(WebSocketSession session, Object data) {
@@ -297,7 +447,15 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
String json = objectMapper.writeValueAsString(data);
session.sendMessage(new TextMessage(json));
} catch (IOException e) {
logger.error("Error sending message to session {}: {}", session.getId(), e.getMessage());
logger.error("向会话 {} 发送消息失败: {}", session.getId(), e.getMessage());
}
}
/** 向发送方回送一条 REQUEST_ERROR 业务提示(不关闭连接)。 */
private void sendError(WebSocketSession session, String message) {
Map<String, Object> response = new HashMap<>();
response.put("type", "REQUEST_ERROR");
response.put("payload", message);
sendToSession(session, response);
}
}

View File

@@ -1,5 +1,6 @@
package com.ttstd.signaling.manager;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceInfo;
import com.ttstd.signaling.model.DeviceType;
import org.slf4j.Logger;
@@ -14,6 +15,12 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 在线会话管理。
*
* <p>所有注册信息均来源于握手阶段已鉴权的 {@link AuthPrincipal}
* 客户端无法通过消息自行声明身份。
*/
@Component
public class SessionManager {
@@ -27,13 +34,26 @@ public class SessionManager {
private final Map<String, String> sessionToDevice = new ConcurrentHashMap<>();
// deviceId -> 注册(上线)时间戳
private final Map<String, Long> connectTime = new ConcurrentHashMap<>();
// deviceId -> 已鉴权主体
private final Map<String, AuthPrincipal> principals = new ConcurrentHashMap<>();
// principalId -> 该主体当前占用的 deviceId 集合(一个账号可能多端登录)
private final Map<String, Set<String>> principalDevices = new ConcurrentHashMap<>();
public void registerDevice(String deviceId, DeviceType deviceType, WebSocketSession session) {
/**
* 注册已鉴权的连接。deviceId 与 deviceType 均取自 {@link AuthPrincipal}。
*/
public void registerDevice(AuthPrincipal principal, WebSocketSession session) {
String deviceId = principal.deviceId();
sessions.put(deviceId, session);
deviceTypes.put(deviceId, deviceType);
deviceTypes.put(deviceId, principal.deviceType());
sessionToDevice.put(session.getId(), deviceId);
connectTime.put(deviceId, System.currentTimeMillis());
logger.info("Device registered: {} ({})", deviceId, deviceType);
principals.put(deviceId, principal);
principalDevices
.computeIfAbsent(principal.principalId(), k -> ConcurrentHashMap.newKeySet())
.add(deviceId);
logger.info("连接已注册: deviceId={} type={} principal={}",
deviceId, principal.deviceType(), principal.principalId());
}
public void unregisterSession(WebSocketSession session) {
@@ -42,7 +62,17 @@ public class SessionManager {
sessions.remove(deviceId);
deviceTypes.remove(deviceId);
connectTime.remove(deviceId);
logger.info("Device unregistered: {}", deviceId);
AuthPrincipal principal = principals.remove(deviceId);
if (principal != null) {
Set<String> owned = principalDevices.get(principal.principalId());
if (owned != null) {
owned.remove(deviceId);
if (owned.isEmpty()) {
principalDevices.remove(principal.principalId());
}
}
}
logger.info("连接已注销: {}", deviceId);
}
}
@@ -50,6 +80,16 @@ public class SessionManager {
return sessions.get(deviceId);
}
public AuthPrincipal getPrincipal(String deviceId) {
return principals.get(deviceId);
}
/** 返回指定主体当前所有在线连接的 deviceId。 */
public Set<String> getDeviceIdsByPrincipal(String principalId) {
Set<String> owned = principalDevices.get(principalId);
return owned == null ? Set.of() : Set.copyOf(owned);
}
public boolean isDeviceOnline(String deviceId) {
WebSocketSession session = sessions.get(deviceId);
return session != null && session.isOpen();

View File

@@ -13,6 +13,7 @@ public class SignalMetrics {
private final AtomicLong totalOffers = new AtomicLong(0);
private final AtomicLong totalAnswers = new AtomicLong(0);
private final AtomicLong totalRejected = new AtomicLong(0);
private final AtomicLong totalBlockedOffers = new AtomicLong(0);
private final AtomicLong totalRegistered = new AtomicLong(0);
private final AtomicLong peakSessions = new AtomicLong(0);
public void incMessage() {
@@ -27,6 +28,9 @@ public class SignalMetrics {
public void incRejected() {
totalRejected.incrementAndGet();
}
public void incBlockedOffer() {
totalBlockedOffers.incrementAndGet();
}
public void incRegistered() {
totalRegistered.incrementAndGet();
}
@@ -45,6 +49,7 @@ public class SignalMetrics {
map.put("totalOffers", totalOffers.get());
map.put("totalAnswers", totalAnswers.get());
map.put("totalRejected", totalRejected.get());
map.put("totalBlockedOffers", totalBlockedOffers.get());
map.put("totalRegistered", totalRegistered.get());
map.put("peakSessions", peakSessions.get());
return map;

View File

@@ -0,0 +1,62 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* 骚扰举报被控端对某个主控端账号发起的举报记录P2 风控)。
*/
@Entity
@Table(name = "abuse_report")
public class AbuseReport {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "device_uid", length = 48, nullable = false)
private String deviceUid;
@Column(name = "reported_user_id", length = 48, nullable = false)
private String reportedUserId;
@Column(name = "reason", length = 255)
private String reason;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private ReportStatus status = ReportStatus.PENDING;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
protected AbuseReport() {
}
public AbuseReport(String deviceUid, String reportedUserId, String reason) {
this.deviceUid = deviceUid;
this.reportedUserId = reportedUserId;
this.reason = reason;
this.status = ReportStatus.PENDING;
this.createdAt = Instant.now();
}
public Long getId() { return id; }
public String getDeviceUid() { return deviceUid; }
public String getReportedUserId() { return reportedUserId; }
public String getReason() { return reason; }
public ReportStatus getStatus() { return status; }
public void setStatus(ReportStatus status) { this.status = status; }
public Instant getCreatedAt() { return createdAt; }
public enum ReportStatus { PENDING, HANDLED, DISMISSED }
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.signaling.model;
/**
* 账号 / 设备状态。
*/
public enum AccountStatus {
/** 正常 */
ACTIVE,
/** 临时封禁(到期自动恢复) */
SUSPENDED,
/** 永久封禁 */
BANNED
}

View File

@@ -0,0 +1,30 @@
package com.ttstd.signaling.model;
/**
* 已鉴权主体:由服务端在握手/请求鉴权阶段裁定,客户端无法自行声明。
*
* @param principalType 主体类型(用户 / 设备)
* @param principalId 主体 IDuserId 或 deviceUid
* @param deviceId 在信令网络中的寻址 ID服务端下发不接受客户端自报
* @param deviceType 信令角色CONTROLLER / CONTROLLED
* @param sessionId 登录会话 ID用于精准踢线
* @param displayName 展示名(用户名 / 设备型号),仅用于日志与管理后台
* @param admin 是否为管理员账号
*/
public record AuthPrincipal(
PrincipalType principalType,
String principalId,
String deviceId,
DeviceType deviceType,
String sessionId,
String displayName,
boolean admin) {
public boolean isUser() {
return principalType == PrincipalType.USER;
}
public boolean isDevice() {
return principalType == PrincipalType.DEVICE;
}
}

View File

@@ -0,0 +1,120 @@
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;
/**
* 被控端设备身份(同时作为 JPA 实体持久化到 MySQL
*
* <p>SN 由系统签名应用获取,仅作为服务端内部主键使用;对外一律使用高熵、不可枚举的
* {@code deviceUid},避免主控端通过猜测 SN 定位并骚扰被控端。
*/
@Entity
@Table(name = "device_account")
public class DeviceAccount {
@Id
@Column(name = "device_uid", length = 48, nullable = false)
private String deviceUid;
@Column(name = "sn", length = 64, unique = true, nullable = false)
private String sn;
@Column(name = "secret_hash", length = 100, nullable = false)
private String secretHash;
@Column(name = "model", length = 64)
private String model;
@Column(name = "provisioned_at", nullable = false)
private Instant provisionedAt;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private AccountStatus status = AccountStatus.ACTIVE;
/** 封禁到期时间(仅 SUSPENDED 有效),为空表示不自动恢复 */
@Column(name = "status_until")
private Instant statusUntil;
@Column(name = "status_reason", length = 255)
private String statusReason;
@Column(name = "token_version", nullable = false)
private long tokenVersion = 1L;
@Column(name = "last_online_at")
private Instant lastOnlineAt;
protected DeviceAccount() {
this.deviceUid = null;
this.sn = null;
this.secretHash = null;
this.model = null;
this.provisionedAt = Instant.now();
}
public DeviceAccount(String deviceUid, String sn, String secretHash, String model) {
this.deviceUid = deviceUid;
this.sn = sn;
this.secretHash = secretHash;
this.model = model;
this.provisionedAt = Instant.now();
}
public String getDeviceUid() { return deviceUid; }
public String getSn() { return sn; }
public Instant getProvisionedAt() { return provisionedAt; }
public String getSecretHash() { return secretHash; }
public void setSecretHash(String secretHash) { this.secretHash = secretHash; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public AccountStatus getStatus() { return status; }
public void setStatus(AccountStatus status) { this.status = status; }
public Instant getStatusUntil() { return statusUntil; }
public void setStatusUntil(Instant statusUntil) { this.statusUntil = statusUntil; }
public String getStatusReason() { return statusReason; }
public void setStatusReason(String statusReason) { this.statusReason = statusReason; }
public long getTokenVersion() { return tokenVersion; }
public void setTokenVersion(long tokenVersion) { this.tokenVersion = tokenVersion; }
public Instant getLastOnlineAt() { return lastOnlineAt; }
public void setLastOnlineAt(Instant lastOnlineAt) { this.lastOnlineAt = lastOnlineAt; }
public boolean isUsable() {
if (status == AccountStatus.BANNED) {
return false;
}
if (status == AccountStatus.SUSPENDED) {
return statusUntil != null && Instant.now().isAfter(statusUntil);
}
return true;
}
/** 日志脱敏SN 仅展示后 4 位。 */
public String maskedSn() {
return maskSn(sn);
}
public static String maskSn(String sn) {
if (sn == null || sn.isEmpty()) {
return "unknown";
}
if (sn.length() <= 4) {
return "****";
}
return "****" + sn.substring(sn.length() - 4);
}
}

View File

@@ -0,0 +1,50 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* 被控端 SN 白名单:仅白名单内的 SN 允许激活provision
* 由管理后台批量导入,作为 JPA 实体持久化到 MySQL。
*/
@Entity
@Table(name = "device_allowlist")
public class DeviceAllowlist {
@Id
@Column(name = "sn", length = 64, nullable = false)
private String sn;
@Column(name = "batch", length = 64)
private String batch;
@Column(name = "imported_by", length = 64)
private String importedBy;
@Column(name = "imported_at", nullable = false)
private Instant importedAt;
protected DeviceAllowlist() {
this.sn = null;
this.importedAt = Instant.now();
}
public DeviceAllowlist(String sn, String batch, String importedBy) {
this.sn = sn;
this.batch = batch;
this.importedBy = importedBy;
this.importedAt = Instant.now();
}
public String getSn() { return sn; }
public String getBatch() { return batch; }
public void setBatch(String batch) { this.batch = batch; }
public String getImportedBy() { return importedBy; }
public void setImportedBy(String importedBy) { this.importedBy = importedBy; }
public Instant getImportedAt() { return importedAt; }
public void setImportedAt(Instant importedAt) { this.importedAt = importedAt; }
}

View File

@@ -0,0 +1,101 @@
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;
/**
* 绑定关系:主控端账号与受控端设备之间的授权连接。
*
* <p>只有存在 {@code ACTIVE} 绑定的主控端才被允许向该设备发起信令OFFER
* 绑定关系由被控端(设备令牌)或管理员创建,可从 {@code REVOKED} 恢复为 {@code ACTIVE}。
*/
@Entity
@Table(name = "device_binding")
public class DeviceBinding {
/** 绑定标识(对外 ID高熵不可枚举 */
@Id
@Column(name = "binding_id", length = 48, nullable = false)
private String bindingId;
/** 被控端设备 UID */
@Column(name = "device_uid", length = 48, nullable = false)
private String deviceUid;
/** 主控端用户 ID对应 user_account.user_id */
@Column(name = "user_id", length = 48, nullable = false)
private String userId;
/** 绑定角色OWNER可管理其他绑定/ MEMBER仅连接 */
@Enumerated(EnumType.STRING)
@Column(name = "role", length = 16, nullable = false)
private BindingRole role = BindingRole.MEMBER;
/** 主控端为设备设置的别名 */
@Column(name = "alias", length = 64)
private String alias;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private BindingStatus status = BindingStatus.ACTIVE;
/** 创建者(设备 UID 或 admin 标识) */
@Column(name = "bound_by", length = 64)
private String boundBy;
@Column(name = "bound_at", nullable = false)
private Instant boundAt = Instant.now();
@Column(name = "expire_at")
private Instant expireAt;
protected DeviceBinding() {
}
public DeviceBinding(String bindingId, String deviceUid, String userId,
BindingRole role, String alias, String boundBy) {
this.bindingId = bindingId;
this.deviceUid = deviceUid;
this.userId = userId;
this.role = role;
this.alias = alias;
this.boundBy = boundBy;
this.boundAt = Instant.now();
}
public String getBindingId() { return bindingId; }
public String getDeviceUid() { return deviceUid; }
public String getUserId() { return userId; }
public BindingRole getRole() { return role; }
public void setRole(BindingRole role) { this.role = role; }
public String getAlias() { return alias; }
public void setAlias(String alias) { this.alias = alias; }
public BindingStatus getStatus() { return status; }
public void setStatus(BindingStatus status) { this.status = status; }
public String getBoundBy() { return boundBy; }
public void setBoundBy(String boundBy) { this.boundBy = boundBy; }
public Instant getBoundAt() { return boundAt; }
public Instant getExpireAt() { return expireAt; }
public void setExpireAt(Instant expireAt) { this.expireAt = expireAt; }
public boolean isActive() {
if (status != BindingStatus.ACTIVE) {
return false;
}
return expireAt == null || Instant.now().isBefore(expireAt);
}
public enum BindingRole { OWNER, MEMBER }
public enum BindingStatus { ACTIVE, REVOKED }
}

View File

@@ -0,0 +1,85 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.time.Instant;
/**
* 被控端黑名单:被控端拉黑的主控端账号。
*
* <p>当被拉黑的主控端向该设备发起 OFFER 时,服务端在转发前直接拒绝,
* 即便双方存在绑定关系也不予放行(黑名单优先级高于绑定)。
*/
@Entity
@Table(name = "device_blacklist")
@IdClass(DeviceBlacklistEntry.PK.class)
public class DeviceBlacklistEntry {
@Id
@Column(name = "device_uid", length = 48, nullable = false)
private String deviceUid;
@Id
@Column(name = "blocked_user_id", length = 48, nullable = false)
private String blockedUserId;
@Column(name = "reason", length = 255)
private String reason;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
protected DeviceBlacklistEntry() {
}
public DeviceBlacklistEntry(String deviceUid, String blockedUserId, String reason) {
this.deviceUid = deviceUid;
this.blockedUserId = blockedUserId;
this.reason = reason;
this.createdAt = Instant.now();
}
public String getDeviceUid() { return deviceUid; }
public String getBlockedUserId() { return blockedUserId; }
public String getReason() { return reason; }
public void setReason(String reason) { this.reason = reason; }
public Instant getCreatedAt() { return createdAt; }
/** 复合主键 */
public static class PK implements Serializable {
private String deviceUid;
private String blockedUserId;
public PK() {
}
public PK(String deviceUid, String blockedUserId) {
this.deviceUid = deviceUid;
this.blockedUserId = blockedUserId;
}
public String getDeviceUid() { return deviceUid; }
public void setDeviceUid(String deviceUid) { this.deviceUid = deviceUid; }
public String getBlockedUserId() { return blockedUserId; }
public void setBlockedUserId(String blockedUserId) { this.blockedUserId = blockedUserId; }
@Override
public boolean equals(Object o) {
if (!(o instanceof PK pk)) {
return false;
}
return java.util.Objects.equals(deviceUid, pk.deviceUid)
&& java.util.Objects.equals(blockedUserId, pk.blockedUserId);
}
@Override
public int hashCode() {
return java.util.Objects.hash(deviceUid, blockedUserId);
}
}
}

View File

@@ -0,0 +1,105 @@
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;
/**
* 登录会话:记录一次登录产生的刷新令牌上下文,支持会话查看、单独踢出与刷新令牌轮转。
* 同时作为 JPA 实体持久化到 MySQL。
*/
@Entity
@Table(name = "login_session")
public class LoginSession {
@Id
@Column(name = "session_id", length = 48, nullable = false)
private String sessionId;
@Column(name = "principal_id", length = 64, nullable = false)
private String principalId;
@Enumerated(EnumType.STRING)
@Column(name = "principal_type", length = 16, nullable = false)
private PrincipalType principalType;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "ip", length = 64)
private String ip;
@Column(name = "user_agent", length = 256)
private String userAgent;
/** 当前有效刷新令牌的哈希;轮转后旧值失效 */
@Column(name = "refresh_token_hash", length = 100, nullable = false)
private String refreshTokenHash;
@Column(name = "refresh_expires_at", nullable = false)
private Instant refreshExpiresAt;
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt;
@Column(name = "revoked", nullable = false)
private boolean revoked;
protected LoginSession() {
this.sessionId = null;
this.principalId = null;
this.principalType = null;
this.refreshTokenHash = null;
this.refreshExpiresAt = Instant.now();
this.ip = null;
this.userAgent = null;
this.createdAt = Instant.now();
this.lastSeenAt = this.createdAt;
}
public LoginSession(String sessionId,
String principalId,
PrincipalType principalType,
String refreshTokenHash,
Instant refreshExpiresAt,
String ip,
String userAgent) {
this.sessionId = sessionId;
this.principalId = principalId;
this.principalType = principalType;
this.refreshTokenHash = refreshTokenHash;
this.refreshExpiresAt = refreshExpiresAt;
this.ip = ip;
this.userAgent = userAgent;
this.createdAt = Instant.now();
this.lastSeenAt = this.createdAt;
}
public String getSessionId() { return sessionId; }
public String getPrincipalId() { return principalId; }
public PrincipalType getPrincipalType() { return principalType; }
public Instant getCreatedAt() { return createdAt; }
public String getIp() { return ip; }
public String getUserAgent() { return userAgent; }
public String getRefreshTokenHash() { return refreshTokenHash; }
public void setRefreshTokenHash(String refreshTokenHash) { this.refreshTokenHash = refreshTokenHash; }
public Instant getRefreshExpiresAt() { return refreshExpiresAt; }
public void setRefreshExpiresAt(Instant refreshExpiresAt) { this.refreshExpiresAt = refreshExpiresAt; }
public Instant getLastSeenAt() { return lastSeenAt; }
public void setLastSeenAt(Instant lastSeenAt) { this.lastSeenAt = lastSeenAt; }
public boolean isRevoked() { return revoked; }
public void setRevoked(boolean revoked) { this.revoked = revoked; }
public boolean isActive() {
return !revoked && refreshExpiresAt != null && Instant.now().isBefore(refreshExpiresAt);
}
}

View File

@@ -0,0 +1,61 @@
package com.ttstd.signaling.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* 一次性配对码:用于被控端向主控端「授权绑定」。
*
* <p>流程:被控端生成配对码(明文仅回显一次)→ 主控端输入码 → 服务端校验并创建绑定。
* 配对码存其 SHA-256 摘要,明文不落库;设有有效期、使用次数与错误尝试上限,防爆破。
*/
@Entity
@Table(name = "pairing_code")
public class PairingCode {
@Id
@Column(name = "code_hash", length = 64, nullable = false)
private String codeHash;
@Column(name = "device_uid", length = 48, nullable = false)
private String deviceUid;
@Column(name = "attempts", nullable = false)
private int attempts = 0;
@Column(name = "used", nullable = false)
private boolean used = false;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
protected PairingCode() {
}
public PairingCode(String codeHash, String deviceUid, Instant expiresAt) {
this.codeHash = codeHash;
this.deviceUid = deviceUid;
this.expiresAt = expiresAt;
this.createdAt = Instant.now();
}
public String getCodeHash() { return codeHash; }
public String getDeviceUid() { return deviceUid; }
public int getAttempts() { return attempts; }
public void setAttempts(int attempts) { this.attempts = attempts; }
public boolean isUsed() { return used; }
public void setUsed(boolean used) { this.used = used; }
public Instant getExpiresAt() { return expiresAt; }
public Instant getCreatedAt() { return createdAt; }
public boolean isExpired() {
return Instant.now().isAfter(expiresAt);
}
}

View File

@@ -0,0 +1,11 @@
package com.ttstd.signaling.model;
/**
* 连接主体类型:区分“用户账号”(主控端)与“设备身份”(被控端)。
*/
public enum PrincipalType {
/** 主控端:由用户账号登录后取得身份 */
USER,
/** 被控端:由设备激活后取得身份(系统签名应用,可获取固定 SN */
DEVICE
}

View File

@@ -0,0 +1,198 @@
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;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 主控端用户账号(同时作为 JPA 实体持久化到 MySQL
*/
@Entity
@Table(name = "app_user")
public class UserAccount {
/** 密码历史保留条数,用于禁止复用近期密码 */
public static final int PASSWORD_HISTORY_SIZE = 5;
@Id
@Column(name = "user_id", length = 64, nullable = false)
private String userId;
@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 = "created_at", nullable = false)
private Instant createdAt;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 16, nullable = false)
private AccountStatus status = AccountStatus.ACTIVE;
/** 封禁到期时间(仅 SUSPENDED 有效),为空表示不自动恢复 */
@Column(name = "status_until")
private Instant statusUntil;
@Column(name = "status_reason", length = 255)
private String statusReason;
/**
* 凭据版本号:递增后所有已签发的 token 立即失效。
* 用于封禁、改密、全端下线等场景。
*/
@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;
@Column(name = "locked_until")
private Instant lockedUntil;
@Column(name = "last_login_at")
private Instant lastLoginAt;
@Column(name = "last_login_ip", length = 64)
private String lastLoginIp;
/** TOTP 密钥Base32。为空表示未生成生成后需 totpEnabled=true 才生效 */
@Column(name = "totp_secret", length = 64)
private String totpSecret;
/** TOTP 是否已完成绑定并启用 */
@Column(name = "totp_enabled", nullable = false)
private boolean totpEnabled;
/**
* 近期密码哈希历史,以换行分隔,用于禁止复用最近 {@value #PASSWORD_HISTORY_SIZE} 个密码。
* <p>存为单列可避免额外建表,条数很少(默认 5不影响性能。
*/
@Column(name = "password_history", length = 1024)
private String passwordHistory;
/** 密码最后修改时间,用于密码过期策略 */
@Column(name = "password_changed_at")
private Instant passwordChangedAt;
protected UserAccount() {
this.userId = null;
this.username = null;
this.passwordHash = null;
this.createdAt = Instant.now();
}
public UserAccount(String userId, String username, String passwordHash) {
this.userId = userId;
this.username = username;
this.passwordHash = passwordHash;
this.createdAt = Instant.now();
}
public String getUserId() { return userId; }
public String getUsername() { return username; }
public Instant getCreatedAt() { return createdAt; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public AccountStatus getStatus() { return status; }
public void setStatus(AccountStatus status) { this.status = status; }
public Instant getStatusUntil() { return statusUntil; }
public void setStatusUntil(Instant statusUntil) { this.statusUntil = statusUntil; }
public String getStatusReason() { return statusReason; }
public void setStatusReason(String statusReason) { this.statusReason = statusReason; }
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; }
public Instant getLockedUntil() { return lockedUntil; }
public void setLockedUntil(Instant lockedUntil) { this.lockedUntil = lockedUntil; }
public Instant getLastLoginAt() { return lastLoginAt; }
public void setLastLoginAt(Instant lastLoginAt) { this.lastLoginAt = lastLoginAt; }
public String getLastLoginIp() { return lastLoginIp; }
public void setLastLoginIp(String lastLoginIp) { this.lastLoginIp = lastLoginIp; }
public String getTotpSecret() { return totpSecret; }
public void setTotpSecret(String totpSecret) { this.totpSecret = totpSecret; }
public boolean isTotpEnabled() { return totpEnabled; }
public void setTotpEnabled(boolean totpEnabled) { this.totpEnabled = totpEnabled; }
public String getPasswordHistory() { return passwordHistory; }
public void setPasswordHistory(String passwordHistory) { this.passwordHistory = passwordHistory; }
public Instant getPasswordChangedAt() { return passwordChangedAt; }
public void setPasswordChangedAt(Instant passwordChangedAt) { this.passwordChangedAt = passwordChangedAt; }
/** 返回历史密码哈希列表(最新在前)。 */
public List<String> passwordHistoryList() {
if (passwordHistory == null || passwordHistory.isBlank()) {
return List.of();
}
return Arrays.stream(passwordHistory.split("\n"))
.filter(s -> !s.isBlank())
.toList();
}
/**
* 将当前密码哈希追加进历史,并裁剪到 {@value #PASSWORD_HISTORY_SIZE} 条。
*/
public void pushPasswordHistory(String hash) {
if (hash == null || hash.isBlank()) {
return;
}
List<String> history = new ArrayList<>();
history.add(hash);
for (String old : passwordHistoryList()) {
if (history.size() >= PASSWORD_HISTORY_SIZE) {
break;
}
if (!old.equals(hash)) {
history.add(old);
}
}
this.passwordHistory = String.join("\n", history);
}
/**
* 判断账号当前是否可用。SUSPENDED 且已过期时视为可用(由调用方负责回写状态)。
*/
public boolean isUsable() {
if (status == AccountStatus.BANNED) {
return false;
}
if (status == AccountStatus.SUSPENDED) {
return statusUntil != null && Instant.now().isAfter(statusUntil);
}
return true;
}
/** 当前是否处于登录失败锁定期。 */
public boolean isLocked() {
return lockedUntil != null && Instant.now().isBefore(lockedUntil);
}
}

View File

@@ -0,0 +1,15 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.AbuseReport;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface AbuseReportRepository extends JpaRepository<AbuseReport, Long> {
List<AbuseReport> findByReportedUserId(String reportedUserId);
List<AbuseReport> findByStatus(AbuseReport.ReportStatus status);
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.DeviceAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface DeviceAccountRepository extends JpaRepository<DeviceAccount, String> {
Optional<DeviceAccount> findBySn(String sn);
boolean existsBySn(String sn);
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.DeviceAllowlist;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface DeviceAllowlistRepository extends JpaRepository<DeviceAllowlist, String> {
List<DeviceAllowlist> findAll();
boolean existsBySn(String sn);
}

View File

@@ -0,0 +1,20 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.DeviceBinding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface DeviceBindingRepository extends JpaRepository<DeviceBinding, String> {
List<DeviceBinding> findByDeviceUid(String deviceUid);
List<DeviceBinding> findByUserId(String userId);
Optional<DeviceBinding> findByDeviceUidAndUserId(String deviceUid, String userId);
List<DeviceBinding> findByDeviceUidAndStatus(String deviceUid, DeviceBinding.BindingStatus status);
}

View File

@@ -0,0 +1,19 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.DeviceBlacklistEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface DeviceBlacklistRepository
extends JpaRepository<DeviceBlacklistEntry, DeviceBlacklistEntry.PK> {
List<DeviceBlacklistEntry> findByDeviceUid(String deviceUid);
Optional<DeviceBlacklistEntry> findByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId);
boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId);
}

View File

@@ -0,0 +1,25 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.LoginSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface LoginSessionRepository extends JpaRepository<LoginSession, String> {
List<LoginSession> findByPrincipalId(String principalId);
List<LoginSession> findByPrincipalIdAndPrincipalType(String principalId, com.ttstd.signaling.model.PrincipalType type);
void deleteBySessionId(String sessionId);
long countByPrincipalIdAndPrincipalType(String principalId, com.ttstd.signaling.model.PrincipalType type);
@Modifying
@Query("UPDATE LoginSession s SET s.revoked = true WHERE s.principalId = :principalId")
int revokeByPrincipalId(String principalId);
@Modifying
@Query("UPDATE LoginSession s SET s.revoked = true WHERE s.sessionId = :sessionId")
int revokeBySessionId(String sessionId);
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.PairingCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface PairingCodeRepository extends JpaRepository<PairingCode, String> {
Optional<PairingCode> findByCodeHash(String codeHash);
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.signaling.repository;
import com.ttstd.signaling.model.UserAccount;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserAccountRepository extends JpaRepository<UserAccount, String> {
Optional<UserAccount> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@@ -0,0 +1,53 @@
package com.ttstd.signaling.security;
/**
* 鉴权/账号相关业务异常。
*
* <p>{@code publicMessage} 是可返回给客户端的模糊提示,
* {@code detail} 仅用于服务端日志,避免向外泄露账号是否存在、设备是否在线等信息。
*/
public class AuthException extends RuntimeException {
private final int status;
private final String code;
private final String publicMessage;
public AuthException(int status, String code, String publicMessage) {
this(status, code, publicMessage, publicMessage);
}
public AuthException(int status, String code, String publicMessage, String detail) {
super(detail);
this.status = status;
this.code = code;
this.publicMessage = publicMessage;
}
public int getStatus() { return status; }
public String getCode() { return code; }
public String getPublicMessage() { return publicMessage; }
public static AuthException unauthorized(String detail) {
return new AuthException(401, "UNAUTHORIZED", "认证失败", detail);
}
public static AuthException forbidden(String publicMessage, String detail) {
return new AuthException(403, "FORBIDDEN", publicMessage, detail);
}
public static AuthException forbidden(String publicMessage) {
return new AuthException(403, "FORBIDDEN", publicMessage, publicMessage);
}
public static AuthException badRequest(String publicMessage) {
return new AuthException(400, "BAD_REQUEST", publicMessage);
}
public static AuthException notFound(String publicMessage) {
return new AuthException(404, "NOT_FOUND", publicMessage);
}
public static AuthException tooManyRequests(String publicMessage) {
return new AuthException(429, "TOO_MANY_REQUESTS", publicMessage);
}
}

View File

@@ -0,0 +1,193 @@
package com.ttstd.signaling.security;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.service.AccountService;
import com.ttstd.signaling.service.DeviceIdentityService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.net.URI;
import java.util.List;
import java.util.Map;
/**
* WebSocket 握手鉴权拦截器。
*
* <p>连接建立前完成身份认定,未通过者直接以 401 拒绝握手,杜绝匿名连接。
* 认证通过后将 {@link AuthPrincipal} 写入会话属性,后续信令处理一律以此为准,
* 客户端自报的 fromDeviceId / deviceType 不再被信任。
*
* <p>令牌传递优先级:
* <ol>
* <li>{@code Sec-WebSocket-Protocol: signal.v1, auth.<token>}(推荐,不进访问日志)</li>
* <li>{@code Authorization: Bearer <token>}(非浏览器客户端可用)</li>
* <li>URL 查询参数 {@code ?token=}(兼容用途,会告警)</li>
* </ol>
*/
@Component
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AuthHandshakeInterceptor.class);
/** 会话属性键:已鉴权主体 */
public static final String ATTR_PRINCIPAL = "authPrincipal";
/** 会话属性键:协商选择的子协议 */
public static final String ATTR_SELECTED_PROTOCOL = "selectedProtocol";
private static final String SUBPROTOCOL = "signal.v1";
private static final String AUTH_PREFIX = "auth.";
/** 单 IP 握手频率限制60 秒内最多 30 次 */
private static final int HANDSHAKE_LIMIT = 30;
private static final long HANDSHAKE_WINDOW_SECONDS = 60;
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
private final RateLimiter rateLimiter;
public AuthHandshakeInterceptor(AccountService accountService,
DeviceIdentityService deviceIdentityService,
RateLimiter rateLimiter) {
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
this.rateLimiter = rateLimiter;
}
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
String clientIp = resolveClientIp(request);
if (!rateLimiter.tryAcquire("ws:" + clientIp, HANDSHAKE_LIMIT, HANDSHAKE_WINDOW_SECONDS)) {
logger.warn("握手请求过于频繁已拒绝ip={}", clientIp);
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return false;
}
String token = extractToken(request);
if (token == null || token.isBlank()) {
logger.warn("握手被拒绝未携带令牌ip={}", clientIp);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
AuthPrincipal principal = resolvePrincipal(token, clientIp);
if (principal == null) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false;
}
attributes.put(ATTR_PRINCIPAL, principal);
// 若客户端使用子协议方式传递令牌,需回显子协议名完成协商
if (requestsSubprotocol(request)) {
attributes.put(ATTR_SELECTED_PROTOCOL, SUBPROTOCOL);
}
logger.info("握手鉴权通过:{} {} (deviceId={}, ip={})",
principal.principalType(), principal.displayName(), principal.deviceId(), clientIp);
return true;
}
/**
* 依次尝试按用户令牌、设备令牌解析身份。
* 两者均失败时统一返回 null不向客户端区分失败原因。
*/
private AuthPrincipal resolvePrincipal(String token, String clientIp) {
try {
return accountService.authenticate(token);
} catch (AuthException userEx) {
try {
return deviceIdentityService.authenticate(token);
} catch (AuthException deviceEx) {
logger.warn("握手鉴权失败ip={},用户令牌校验:{};设备令牌校验:{}",
clientIp, userEx.getMessage(), deviceEx.getMessage());
return null;
}
}
}
private boolean requestsSubprotocol(ServerHttpRequest request) {
List<String> protocols = request.getHeaders().get("Sec-WebSocket-Protocol");
if (protocols == null) {
return false;
}
for (String raw : protocols) {
for (String part : raw.split(",")) {
if (SUBPROTOCOL.equals(part.trim())) {
return true;
}
}
}
return false;
}
/**
* 提取令牌,优先使用不会被写入访问日志的传递方式。
*/
private String extractToken(ServerHttpRequest request) {
// 1) Sec-WebSocket-Protocol: signal.v1, auth.<token>
List<String> protocols = request.getHeaders().get("Sec-WebSocket-Protocol");
if (protocols != null) {
for (String raw : protocols) {
for (String part : raw.split(",")) {
String value = part.trim();
if (value.startsWith(AUTH_PREFIX) && value.length() > AUTH_PREFIX.length()) {
return value.substring(AUTH_PREFIX.length());
}
}
}
}
// 2) Authorization: Bearer <token>
String authorization = request.getHeaders().getFirst("Authorization");
if (authorization != null && authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return authorization.substring(7).trim();
}
// 3) URL 查询参数(兼容旧客户端,存在写入日志的风险)
URI uri = request.getURI();
String query = uri.getQuery();
if (query != null) {
for (String pair : query.split("&")) {
int idx = pair.indexOf('=');
if (idx > 0 && "token".equals(pair.substring(0, idx))) {
logger.warn("客户端通过 URL 查询参数传递令牌,存在日志泄露风险,建议改用 Sec-WebSocket-Protocol");
return java.net.URLDecoder.decode(
pair.substring(idx + 1), java.nio.charset.StandardCharsets.UTF_8);
}
}
}
return null;
}
private String resolveClientIp(ServerHttpRequest request) {
String forwarded = request.getHeaders().getFirst("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
int comma = forwarded.indexOf(',');
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
if (request instanceof ServletServerHttpRequest servletRequest) {
return servletRequest.getServletRequest().getRemoteAddr();
}
return request.getRemoteAddress() == null
? "unknown"
: request.getRemoteAddress().getAddress().getHostAddress();
}
@Override
public void afterHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Exception exception) {
// 无需处理
}
}

View File

@@ -0,0 +1,185 @@
package com.ttstd.signaling.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 轻量 JWTHS256签发与校验服务。
*
* <p>不引入额外 JWT 库,直接基于 JDK 的 HMAC-SHA256 实现,减少依赖面。
* 签名比对使用 {@link MessageDigest#isEqual} 以规避时序侧信道。
*/
@Service
public class JwtService {
private static final Logger logger = LoggerFactory.getLogger(JwtService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder();
/** 令牌用途,防止 access/refresh/device 令牌互相冒用 */
public static final String PURPOSE_ACCESS = "access";
public static final String PURPOSE_REFRESH = "refresh";
public static final String PURPOSE_DEVICE = "device";
private final SecurityProperties properties;
private final byte[] signingKey;
public JwtService(SecurityProperties properties) {
this.properties = properties;
String configured = properties.getJwt().getSecret();
if (configured == null || configured.isBlank()) {
byte[] random = new byte[48];
new SecureRandom().nextBytes(random);
this.signingKey = random;
logger.warn("未配置 security.jwt.secret已生成随机密钥。服务重启后所有令牌将失效"
+ "且多实例部署无法互认,生产环境请通过环境变量 JWT_SECRET 配置固定密钥。");
} else if (configured.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("security.jwt.secret 长度不足,至少需要 32 字节");
} else {
this.signingKey = configured.getBytes(StandardCharsets.UTF_8);
}
}
/**
* 签发令牌。
*
* @param subject 主体 ID
* @param purpose 令牌用途
* @param ttlSeconds 有效期(秒)
* @param tokenVersion 凭据版本号,与账号当前版本不一致即视为失效
* @param extraClaims 附加声明
*/
public String issue(String subject,
String purpose,
long ttlSeconds,
long tokenVersion,
Map<String, Object> extraClaims) {
Instant now = Instant.now();
Map<String, Object> header = new LinkedHashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
Map<String, Object> claims = new LinkedHashMap<>();
claims.put("iss", properties.getJwt().getIssuer());
claims.put("sub", subject);
claims.put("iat", now.getEpochSecond());
claims.put("exp", now.plusSeconds(ttlSeconds).getEpochSecond());
claims.put("jti", randomId());
claims.put("pur", purpose);
claims.put("ver", tokenVersion);
if (extraClaims != null) {
claims.putAll(extraClaims);
}
try {
String headerPart = B64URL.encodeToString(MAPPER.writeValueAsBytes(header));
String payloadPart = B64URL.encodeToString(MAPPER.writeValueAsBytes(claims));
String signingInput = headerPart + "." + payloadPart;
String signature = B64URL.encodeToString(hmacSha256(signingInput));
return signingInput + "." + signature;
} catch (Exception e) {
throw new IllegalStateException("签发令牌失败", e);
}
}
/**
* 校验令牌签名、有效期与用途,返回声明集合。
*
* @throws AuthException 校验失败
*/
public Map<String, Object> verify(String token, String expectedPurpose) {
if (token == null || token.isBlank()) {
throw AuthException.unauthorized("令牌为空");
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw AuthException.unauthorized("令牌格式非法");
}
String signingInput = parts[0] + "." + parts[1];
byte[] expected = hmacSha256(signingInput);
byte[] actual;
try {
actual = B64URL_DEC.decode(parts[2]);
} catch (IllegalArgumentException e) {
throw AuthException.unauthorized("令牌签名编码非法");
}
// 常量时间比较,避免时序侧信道
if (!MessageDigest.isEqual(expected, actual)) {
throw AuthException.unauthorized("令牌签名无效");
}
Map<String, Object> claims;
try {
byte[] payload = B64URL_DEC.decode(parts[1]);
@SuppressWarnings("unchecked")
Map<String, Object> parsed = MAPPER.readValue(payload, Map.class);
claims = parsed;
} catch (Exception e) {
throw AuthException.unauthorized("令牌载荷解析失败");
}
long skew = properties.getJwt().getClockSkewSeconds();
long now = Instant.now().getEpochSecond();
Object exp = claims.get("exp");
if (!(exp instanceof Number expNum) || now > expNum.longValue() + skew) {
throw AuthException.unauthorized("令牌已过期");
}
Object iat = claims.get("iat");
if (iat instanceof Number iatNum && now + skew < iatNum.longValue()) {
throw AuthException.unauthorized("令牌签发时间异常");
}
if (!properties.getJwt().getIssuer().equals(claims.get("iss"))) {
throw AuthException.unauthorized("令牌签发方不匹配");
}
if (expectedPurpose != null && !expectedPurpose.equals(claims.get("pur"))) {
throw AuthException.unauthorized("令牌用途不匹配");
}
return claims;
}
public static String claimAsString(Map<String, Object> claims, String key) {
Object v = claims.get(key);
return v == null ? null : String.valueOf(v);
}
public static long claimAsLong(Map<String, Object> claims, String key, long defaultValue) {
Object v = claims.get(key);
return v instanceof Number n ? n.longValue() : defaultValue;
}
public Map<String, Object> newClaims() {
return new HashMap<>();
}
private byte[] hmacSha256(String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingKey, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("HMAC 计算失败", e);
}
}
private static String randomId() {
byte[] buf = new byte[16];
new SecureRandom().nextBytes(buf);
return B64URL.encodeToString(buf);
}
}

View File

@@ -0,0 +1,62 @@
package com.ttstd.signaling.security;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 简易固定窗口限流器,用于登录、激活、握手等敏感入口的防爆破。
*
* <p>当前为单机内存实现;多实例部署时应替换为 Redis 计数。
*/
@Component
public class RateLimiter {
private static final class Window {
final AtomicInteger count = new AtomicInteger();
volatile Instant resetAt;
Window(Instant resetAt) {
this.resetAt = resetAt;
}
}
private final Map<String, Window> windows = new ConcurrentHashMap<>();
/**
* 尝试消费一次配额。
*
* @param key 限流键(如 "login:" + ip
* @param maxRequests 窗口内最大次数
* @param windowSeconds 窗口长度(秒)
* @return true 表示允许false 表示已超限
*/
public boolean tryAcquire(String key, int maxRequests, long windowSeconds) {
Instant now = Instant.now();
Window window = windows.compute(key, (k, existing) -> {
if (existing == null || now.isAfter(existing.resetAt)) {
return new Window(now.plusSeconds(windowSeconds));
}
return existing;
});
return window.count.incrementAndGet() <= maxRequests;
}
/** 重置指定键的计数(例如登录成功后清空失败计数)。 */
public void reset(String key) {
windows.remove(key);
}
/** 清理已过期窗口,避免内存无限增长。由定时任务调用。 */
public void evictExpired() {
Instant now = Instant.now();
windows.entrySet().removeIf(e -> now.isAfter(e.getValue().resetAt));
}
public int size() {
return windows.size();
}
}

View File

@@ -0,0 +1,118 @@
package com.ttstd.signaling.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 安全相关配置项,对应 application.yml 中的 {@code security.*}。
*/
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
private final Jwt jwt = new Jwt();
private final Device device = new Device();
private final Account account = new Account();
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 WebSocket getWebsocket() { return websocket; }
public Turn getTurn() { return turn; }
public static class Jwt {
private String secret;
private String issuer = "webrtc-signal-server";
private long accessTokenTtlSeconds = 900;
private long refreshTokenTtlSeconds = 604800;
private long deviceTokenTtlSeconds = 900;
private long clockSkewSeconds = 60;
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public long getAccessTokenTtlSeconds() { return accessTokenTtlSeconds; }
public void setAccessTokenTtlSeconds(long v) { this.accessTokenTtlSeconds = v; }
public long getRefreshTokenTtlSeconds() { return refreshTokenTtlSeconds; }
public void setRefreshTokenTtlSeconds(long v) { this.refreshTokenTtlSeconds = v; }
public long getDeviceTokenTtlSeconds() { return deviceTokenTtlSeconds; }
public void setDeviceTokenTtlSeconds(long v) { this.deviceTokenTtlSeconds = v; }
public long getClockSkewSeconds() { return clockSkewSeconds; }
public void setClockSkewSeconds(long v) { this.clockSkewSeconds = v; }
}
public static class Device {
private String provisionSecret;
private long provisionSkewSeconds = 300;
private boolean snAllowlistEnabled = false;
public String getProvisionSecret() { return provisionSecret; }
public void setProvisionSecret(String provisionSecret) { this.provisionSecret = provisionSecret; }
public long getProvisionSkewSeconds() { return provisionSkewSeconds; }
public void setProvisionSkewSeconds(long v) { this.provisionSkewSeconds = v; }
public boolean isSnAllowlistEnabled() { return snAllowlistEnabled; }
public void setSnAllowlistEnabled(boolean v) { this.snAllowlistEnabled = v; }
}
public static class Account {
private boolean registrationEnabled = true;
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; }
public int getMaxFailedAttempts() { return maxFailedAttempts; }
public void setMaxFailedAttempts(int v) { this.maxFailedAttempts = v; }
public long getLockDurationSeconds() { return lockDurationSeconds; }
public void setLockDurationSeconds(long v) { this.lockDurationSeconds = v; }
public int getMaxConcurrentSessions() { return maxConcurrentSessions; }
public void setMaxConcurrentSessions(int v) { this.maxConcurrentSessions = v; }
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 static class WebSocket {
private String allowedOrigins = "*";
public String getAllowedOrigins() { return allowedOrigins; }
public void setAllowedOrigins(String v) { this.allowedOrigins = v; }
}
public static class Turn {
/** TURN 共享密钥(短期凭证的 HMAC key生产环境务必通过环境变量注入 */
private String sharedSecret;
/** TURN 服务器地址列表,逗号分隔,如 turn:turn.ttstd.com:3478?transport=udp */
private String urls = "";
/** 短期凭证有效期(秒),到点后凭证失效,客户端需重新获取 */
private long ttlSeconds = 3600;
/** 是否启用 TURN 凭证发放 */
private boolean enabled = false;
public String getSharedSecret() { return sharedSecret; }
public void setSharedSecret(String v) { this.sharedSecret = v; }
public String getUrls() { return urls; }
public void setUrls(String v) { this.urls = v; }
public long getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(long v) { this.ttlSeconds = v; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean v) { this.enabled = v; }
}
}

View File

@@ -0,0 +1,44 @@
package com.ttstd.signaling.security;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
import java.util.List;
import java.util.Map;
/**
* 子协议协商处理器。
*
* <p>客户端以 {@code Sec-WebSocket-Protocol: signal.v1, auth.<token>} 传递令牌时,
* 服务端必须在响应中回显一个已选择的子协议,否则浏览器会判定协商失败并断开连接。
* 此处固定回显 {@code signal.v1},绝不回显携带令牌的那一项。
*/
@Component
public class SubProtocolHandshakeHandler extends DefaultHandshakeHandler {
@Override
protected String selectProtocol(List<String> requestedProtocols, WebSocketHandler webSocketHandler) {
if (requestedProtocols == null) {
return null;
}
for (String protocol : requestedProtocols) {
if ("signal.v1".equals(protocol.trim())) {
return "signal.v1";
}
}
return null;
}
@Override
protected java.security.Principal determineUser(ServerHttpRequest request,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
Object principal = attributes.get(AuthHandshakeInterceptor.ATTR_PRINCIPAL);
if (principal instanceof com.ttstd.signaling.model.AuthPrincipal auth) {
return auth::deviceId;
}
return null;
}
}

View File

@@ -0,0 +1,57 @@
package com.ttstd.signaling.security;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 随机 ID / 密钥生成与哈希工具。
*/
public final class TokenUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final char[] BASE62 =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
private TokenUtils() {
}
/** 生成高熵、不可枚举的 Base62 字符串。 */
public static String randomBase62(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(BASE62[RANDOM.nextInt(BASE62.length)]);
}
return sb.toString();
}
/** 生成 URL-safe 的随机密钥(用于 deviceSecret / refreshToken。 */
public static String randomSecret(int bytes) {
byte[] buf = new byte[bytes];
RANDOM.nextBytes(buf);
return B64URL.encodeToString(buf);
}
/** 对不可逆凭据做 SHA-256 摘要(适用于高熵随机串,无需加盐慢哈希)。 */
public static String sha256(String raw) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return B64URL.encodeToString(md.digest(raw.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
}
/** 常量时间字符串比较。 */
public static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8),
b.getBytes(StandardCharsets.UTF_8));
}
}

View File

@@ -0,0 +1,147 @@
package com.ttstd.signaling.security;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Instant;
/**
* TOTPRFC 6238双因子认证服务。
*
* <p>基于 HMAC-SHA1、30 秒时间步长、6 位动态码,兼容 Google Authenticator /
* Microsoft Authenticator / 1Password 等主流认证器。
*
* <p>校验时允许前后各一个时间窗口±30 秒),以容忍设备时钟偏差。
*/
@Service
public class TotpService {
private static final int DIGITS = 6;
private static final int PERIOD_SECONDS = 30;
/** 允许的时间窗口偏移数量1 表示接受前后各 30 秒 */
private static final int WINDOW = 1;
private static final String BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private final SecureRandom random = new SecureRandom();
/** 生成 Base32 编码的 TOTP 密钥160 bit。 */
public String generateSecret() {
byte[] buf = new byte[20];
random.nextBytes(buf);
return base32Encode(buf);
}
/**
* 构造 otpauth:// URI供客户端生成二维码。
*/
public String buildOtpAuthUri(String issuer, String accountName, String secret) {
String encodedIssuer = URLEncoder.encode(issuer, StandardCharsets.UTF_8);
String encodedAccount = URLEncoder.encode(accountName, StandardCharsets.UTF_8);
return "otpauth://totp/" + encodedIssuer + ":" + encodedAccount
+ "?secret=" + secret
+ "&issuer=" + encodedIssuer
+ "&algorithm=SHA1"
+ "&digits=" + DIGITS
+ "&period=" + PERIOD_SECONDS;
}
/**
* 校验动态码。允许 ±{@value #WINDOW} 个时间窗口的偏差。
*/
public boolean verify(String secret, String code) {
if (secret == null || code == null) {
return false;
}
String normalized = code.trim().replace(" ", "");
if (normalized.length() != DIGITS || !normalized.chars().allMatch(Character::isDigit)) {
return false;
}
byte[] key;
try {
key = base32Decode(secret);
} catch (IllegalArgumentException e) {
return false;
}
long counter = Instant.now().getEpochSecond() / PERIOD_SECONDS;
for (int offset = -WINDOW; offset <= WINDOW; offset++) {
String expected = generateCode(key, counter + offset);
// 常量时间比较,避免时序侧信道
if (TokenUtils.constantTimeEquals(expected, normalized)) {
return true;
}
}
return false;
}
/** 按 RFC 4226 生成 HOTP 码。 */
private String generateCode(byte[] key, long counter) {
byte[] data = new byte[8];
long value = counter;
for (int i = 7; i >= 0; i--) {
data[i] = (byte) (value & 0xFF);
value >>>= 8;
}
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, DIGITS);
return String.format("%0" + DIGITS + "d", otp);
} catch (Exception e) {
throw new IllegalStateException("TOTP 计算失败", e);
}
}
// ==================== Base32 ====================
static String base32Encode(byte[] data) {
StringBuilder sb = new StringBuilder();
int buffer = 0;
int bitsLeft = 0;
for (byte b : data) {
buffer = (buffer << 8) | (b & 0xFF);
bitsLeft += 8;
while (bitsLeft >= 5) {
sb.append(BASE32_ALPHABET.charAt((buffer >> (bitsLeft - 5)) & 0x1F));
bitsLeft -= 5;
}
}
if (bitsLeft > 0) {
sb.append(BASE32_ALPHABET.charAt((buffer << (5 - bitsLeft)) & 0x1F));
}
return sb.toString();
}
static byte[] base32Decode(String encoded) {
String normalized = encoded.trim().replace("=", "").toUpperCase();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("空的 Base32 字符串");
}
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
int buffer = 0;
int bitsLeft = 0;
for (char c : normalized.toCharArray()) {
int index = BASE32_ALPHABET.indexOf(c);
if (index < 0) {
throw new IllegalArgumentException("非法 Base32 字符: " + c);
}
buffer = (buffer << 5) | index;
bitsLeft += 5;
if (bitsLeft >= 8) {
out.write((buffer >> (bitsLeft - 8)) & 0xFF);
bitsLeft -= 8;
}
}
return out.toByteArray();
}
}

View File

@@ -0,0 +1,40 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.AbuseReport;
import com.ttstd.signaling.repository.AbuseReportRepository;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 骚扰举报服务P2 风控):被控端可对骚扰自己的主控端账号提交举报。
*/
@Service
public class AbuseReportService {
private final AbuseReportRepository repository;
public AbuseReportService(AbuseReportRepository repository) {
this.repository = repository;
}
public AbuseReport report(String deviceUid, String reportedUserId, String reason) {
AbuseReport report = new AbuseReport(deviceUid, reportedUserId, reason);
return repository.save(report);
}
public List<AbuseReport> listPending() {
return repository.findByStatus(AbuseReport.ReportStatus.PENDING);
}
public List<AbuseReport> listByReportedUser(String reportedUserId) {
return repository.findByReportedUserId(reportedUserId);
}
public void handle(Long id, AbuseReport.ReportStatus status) {
repository.findById(id).ifPresent(r -> {
r.setStatus(status);
repository.save(r);
});
}
}

View File

@@ -0,0 +1,608 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.LoginSession;
import com.ttstd.signaling.model.PrincipalType;
import com.ttstd.signaling.model.UserAccount;
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.security.TotpService;
import com.ttstd.signaling.store.SessionStore;
import com.ttstd.signaling.store.UserStore;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 主控端账号服务:注册、登录、令牌刷新、登出、封禁与强制下线。
*
* <p>底层存储通过 {@link UserStore}/{@link SessionStore} 抽象,生产环境使用 MySQLJPA
* 测试/演示使用内存实现,调用方无感知。
*/
@Service
public class AccountService {
private static final Logger logger = LoggerFactory.getLogger(AccountService.class);
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]{3,32}$");
/** 常见弱口令黑名单(小写比对)。生产可扩展为从文件加载。 */
private static final Set<String> WEAK_PASSWORDS = Set.of(
"password", "passw0rd", "password1", "password123", "passw0rd!",
"12345678", "123456789", "1234567890", "qwertyui", "qwerty123",
"abc12345", "admin123", "administrator", "letmein1", "welcome1",
"iloveyou", "sunshine", "princess", "football", "baseball",
"monkey123", "dragon123", "master123", "shadow123", "superman");
private final UserStore userStore;
private final SessionStore sessionStore;
private final SecurityProperties properties;
private final JwtService jwtService;
private final TotpService totpService;
private final AuditService auditService;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12);
/** 会话失效回调:由信令层注册,用于立即断开对应 WebSocket 连接 */
private volatile SessionRevocationListener revocationListener;
public AccountService(UserStore userStore,
SessionStore sessionStore,
SecurityProperties properties,
JwtService jwtService,
TotpService totpService,
AuditService auditService) {
this.userStore = userStore;
this.sessionStore = sessionStore;
this.properties = properties;
this.jwtService = jwtService;
this.totpService = totpService;
this.auditService = auditService;
}
/** 会话/账号失效通知接口。 */
public interface SessionRevocationListener {
void onRevoked(String principalId, String sessionId, String reason);
}
public void setRevocationListener(SessionRevocationListener listener) {
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) {
if (!properties.getAccount().isRegistrationEnabled()) {
throw AuthException.forbidden("当前不开放注册", "registration disabled");
}
validateUsername(username);
validatePassword(password, username);
return createAccountInternal(username, password);
}
private UserAccount createAccountInternal(String username, String password) {
String key = username.toLowerCase(Locale.ROOT);
if (userStore.existsByUsername(username)) {
throw AuthException.badRequest("用户名已被占用");
}
String userId = "usr_" + TokenUtils.randomBase62(20);
UserAccount account = new UserAccount(userId, username, passwordEncoder.encode(password));
account.setPasswordChangedAt(Instant.now());
userStore.save(account);
logger.info("账号已创建: {} ({})", username, userId);
auditService.recordUser(userId, AuditService.ACTION_REGISTER,
AuditService.RESULT_SUCCESS, null, "账号注册成功: " + username);
return account;
}
private void validateUsername(String username) {
if (username == null || !USERNAME_PATTERN.matcher(username).matches()) {
throw AuthException.badRequest("用户名需为 3-32 位字母、数字、下划线、点或连字符");
}
}
/**
* 密码强度校验:长度、字符类别、弱口令黑名单、与用户名的相关性。
*/
private void validatePassword(String password, String username) {
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("密码需至少包含大小写字母、数字、符号中的两类");
}
String lower = password.toLowerCase(Locale.ROOT);
if (WEAK_PASSWORDS.contains(lower)) {
throw AuthException.badRequest("密码过于简单,请更换");
}
// 密码不得包含用户名(防止 alice/alice123 这类弱口令)
if (username != null && username.length() >= 3
&& lower.contains(username.toLowerCase(Locale.ROOT))) {
throw AuthException.badRequest("密码不能包含用户名");
}
// 连续或重复字符检测,如 111111 / abcdef
if (hasTrivialSequence(lower)) {
throw AuthException.badRequest("密码不能为连续或重复字符");
}
}
/** 检测全部为同一字符,或为连续递增/递减序列。 */
private static boolean hasTrivialSequence(String password) {
boolean allSame = true;
boolean ascending = true;
boolean descending = true;
for (int i = 1; i < password.length(); i++) {
char prev = password.charAt(i - 1);
char curr = password.charAt(i);
if (curr != prev) allSame = false;
if (curr != prev + 1) ascending = false;
if (curr != prev - 1) descending = false;
}
return allSame || ascending || descending;
}
// ==================== 登录 ====================
/** 兼容旧签名:不带 TOTP 动态码的登录。 */
public TokenPair login(String username, String password, String ip, String userAgent) {
return login(username, password, null, ip, userAgent);
}
/**
* 账号密码登录,支持 TOTP 双因子。
*
* <p>无论用户名是否存在,失败均返回同一模糊提示,避免账号枚举。
*
* @param totpCode 动态码;账号未启用 TOTP 时忽略
*/
public TokenPair login(String username, String password, String totpCode,
String ip, String userAgent) {
UserAccount account = userStore.findByUsername(username).orElse(null);
if (account == null) {
// 执行一次伪哈希,抹平“用户不存在”与“密码错误”的响应时间差
passwordEncoder.encode("dummy-password-for-timing");
auditService.record("USER", username, AuditService.ACTION_LOGIN_FAILED,
null, AuditService.RESULT_FAILURE, ip, "账号不存在");
throw AuthException.unauthorized("username not found: " + username);
}
if (account.isLocked()) {
auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED,
AuditService.RESULT_FAILURE, ip, "账号处于锁定期");
throw AuthException.tooManyRequests("尝试过于频繁,请稍后再试");
}
if (!passwordEncoder.matches(password, account.getPasswordHash())) {
recordLoginFailure(account, ip);
throw AuthException.unauthorized("bad password for " + account.getUserId());
}
// 第二因子校验
if (account.isTotpEnabled()) {
if (totpCode == null || totpCode.isBlank()) {
// 用专门的错误码告知客户端需要补充动态码,而非笼统的认证失败
throw new AuthException(401, "TOTP_REQUIRED", "请输入动态验证码",
"totp required for " + account.getUserId());
}
if (!totpService.verify(account.getTotpSecret(), totpCode)) {
recordLoginFailure(account, ip);
auditService.recordUser(account.getUserId(), AuditService.ACTION_TOTP_FAILED,
AuditService.RESULT_FAILURE, ip, "动态码校验失败");
throw AuthException.unauthorized("bad totp for " + account.getUserId());
}
}
account = assertAccountUsable(account);
account.setFailedAttempts(0);
account.setLockedUntil(null);
account.setLastLoginAt(Instant.now());
account.setLastLoginIp(ip);
userStore.save(account);
enforceSessionLimit(account.getUserId());
TokenPair pair = issueTokens(account, ip, userAgent);
auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN,
AuditService.RESULT_SUCCESS, ip,
account.isTotpEnabled() ? "密码+动态码登录" : "密码登录");
return pair;
}
private void recordLoginFailure(UserAccount account, String ip) {
int attempts = account.getFailedAttempts() + 1;
account.setFailedAttempts(attempts);
if (attempts >= properties.getAccount().getMaxFailedAttempts()) {
account.setLockedUntil(Instant.now()
.plusSeconds(properties.getAccount().getLockDurationSeconds()));
account.setFailedAttempts(0);
logger.warn("账号 {} 连续登录失败已触发锁定", account.getUserId());
auditService.recordUser(account.getUserId(), AuditService.ACTION_ACCOUNT_LOCKED,
AuditService.RESULT_FAILURE, ip, "连续登录失败触发锁定");
}
userStore.save(account);
auditService.recordUser(account.getUserId(), AuditService.ACTION_LOGIN_FAILED,
AuditService.RESULT_FAILURE, ip, "凭据校验失败");
}
/** 校验账号可用性;对已过期的临时封禁自动恢复。返回最新账号对象。 */
private UserAccount assertAccountUsable(UserAccount account) {
if (account.getStatus() == AccountStatus.SUSPENDED
&& account.getStatusUntil() != null
&& Instant.now().isAfter(account.getStatusUntil())) {
account.setStatus(AccountStatus.ACTIVE);
account.setStatusUntil(null);
account.setStatusReason(null);
userStore.save(account);
logger.info("账号 {} 临时封禁已到期,自动恢复", account.getUserId());
}
if (!account.isUsable()) {
String msg = account.getStatus() == AccountStatus.BANNED
? "账号已被封禁" : "账号已被临时封禁";
if (account.getStatusReason() != null && !account.getStatusReason().isBlank()) {
msg = msg + "" + account.getStatusReason();
}
throw AuthException.forbidden(msg, "account not usable: " + account.getUserId());
}
return account;
}
/** 超出最大并发会话数时,踢掉最旧的会话。 */
private void enforceSessionLimit(String userId) {
int max = properties.getAccount().getMaxConcurrentSessions();
if (max <= 0) {
return;
}
List<LoginSession> active = sessionStore.listByPrincipalAndType(userId, PrincipalType.USER).stream()
.filter(LoginSession::isActive)
.sorted(Comparator.comparing(LoginSession::getCreatedAt))
.toList();
int excess = active.size() - (max - 1);
for (int i = 0; i < excess && i < active.size(); i++) {
LoginSession old = active.get(i);
old.setRevoked(true);
sessionStore.save(old);
notifyRevoked(userId, old.getSessionId(), "会话数超限,最旧会话已下线");
logger.info("账号 {} 会话数超限,已踢出最旧会话 {}", userId, old.getSessionId());
}
}
private TokenPair issueTokens(UserAccount account, String ip, String userAgent) {
String sessionId = "ses_" + TokenUtils.randomBase62(20);
String refreshToken = TokenUtils.randomSecret(32);
Instant refreshExpiry = Instant.now()
.plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds());
LoginSession session = new LoginSession(
sessionId, account.getUserId(), PrincipalType.USER,
TokenUtils.sha256(refreshToken), refreshExpiry, ip, userAgent);
sessionStore.save(session);
String accessToken = buildAccessToken(account, sessionId);
return new TokenPair(accessToken, sessionId + "." + refreshToken,
properties.getJwt().getAccessTokenTtlSeconds(),
sessionId, account.getUserId(), account.getUsername());
}
private String buildAccessToken(UserAccount account, String sessionId) {
Map<String, Object> claims = jwtService.newClaims();
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);
}
// ==================== 刷新 ====================
public TokenPair refresh(String compositeRefreshToken, String ip, String userAgent) {
if (compositeRefreshToken == null || !compositeRefreshToken.contains(".")) {
throw AuthException.unauthorized("刷新令牌格式非法");
}
int idx = compositeRefreshToken.indexOf('.');
String sessionId = compositeRefreshToken.substring(0, idx);
String rawToken = compositeRefreshToken.substring(idx + 1);
LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
if (session == null || !session.isActive()) {
throw AuthException.unauthorized("刷新令牌无效或已过期");
}
if (!TokenUtils.constantTimeEquals(session.getRefreshTokenHash(), TokenUtils.sha256(rawToken))) {
session.setRevoked(true);
sessionStore.save(session);
notifyRevoked(session.getPrincipalId(), sessionId, "检测到刷新令牌异常复用,会话已终止");
logger.warn("检测到刷新令牌复用,已吊销会话 {}(主体 {}",
sessionId, session.getPrincipalId());
auditService.recordUser(session.getPrincipalId(), AuditService.ACTION_REFRESH_REUSE,
AuditService.RESULT_FAILURE, ip,
"刷新令牌复用,疑似泄露,已吊销会话 " + sessionId);
throw AuthException.unauthorized("刷新令牌无效");
}
UserAccount account = userStore.findByUserId(session.getPrincipalId()).orElse(null);
if (account == null) {
throw AuthException.unauthorized("账号不存在");
}
account = assertAccountUsable(account);
String newRefresh = TokenUtils.randomSecret(32);
session.setRefreshTokenHash(TokenUtils.sha256(newRefresh));
session.setRefreshExpiresAt(Instant.now()
.plusSeconds(properties.getJwt().getRefreshTokenTtlSeconds()));
session.setLastSeenAt(Instant.now());
sessionStore.save(session);
String accessToken = buildAccessToken(account, sessionId);
return new TokenPair(accessToken, sessionId + "." + newRefresh,
properties.getJwt().getAccessTokenTtlSeconds(),
sessionId, account.getUserId(), account.getUsername());
}
// ==================== 令牌校验 ====================
public AuthPrincipal authenticate(String accessToken) {
Map<String, Object> claims = jwtService.verify(accessToken, JwtService.PURPOSE_ACCESS);
if (!PrincipalType.USER.name().equals(JwtService.claimAsString(claims, "typ"))) {
throw AuthException.unauthorized("令牌主体类型不匹配");
}
String userId = JwtService.claimAsString(claims, "sub");
UserAccount account = userStore.findByUserId(userId).orElse(null);
if (account == null) {
throw AuthException.unauthorized("账号不存在: " + userId);
}
long ver = JwtService.claimAsLong(claims, "ver", -1);
if (ver != account.getTokenVersion()) {
throw AuthException.unauthorized("令牌已失效(凭据版本变更)");
}
account = assertAccountUsable(account);
String sessionId = JwtService.claimAsString(claims, "sid");
LoginSession session = sessionId == null ? null : sessionStore.findBySessionId(sessionId).orElse(null);
if (session == null || !session.isActive()) {
throw AuthException.unauthorized("会话已结束");
}
session.setLastSeenAt(Instant.now());
sessionStore.save(session);
String signalDeviceId = "ctl_" + sessionId;
return new AuthPrincipal(PrincipalType.USER, userId, signalDeviceId,
DeviceType.CONTROLLER, sessionId, account.getUsername(), account.isAdmin());
}
// ==================== 登出 / 封禁 / 踢线 ====================
public void logout(String sessionId) {
LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
if (session != null) {
session.setRevoked(true);
sessionStore.save(session);
notifyRevoked(session.getPrincipalId(), sessionId, "已登出");
logger.info("会话 {} 已登出", sessionId);
}
}
/** 吊销指定账号的全部会话并使所有令牌失效。 */
public void revokeAllSessions(String userId, String reason) {
UserAccount account = userStore.findByUserId(userId).orElse(null);
if (account != null) {
account.setTokenVersion(account.getTokenVersion() + 1);
userStore.save(account);
}
sessionStore.revokeByPrincipal(userId);
notifyRevoked(userId, null, reason);
logger.info("账号 {} 的全部会话已吊销:{}", userId, reason);
}
/** 踢出单个会话。 */
public void revokeSession(String sessionId, String reason) {
LoginSession session = sessionStore.findBySessionId(sessionId).orElse(null);
if (session == null) {
throw AuthException.badRequest("会话不存在");
}
session.setRevoked(true);
sessionStore.save(session);
notifyRevoked(session.getPrincipalId(), sessionId, reason);
logger.info("会话 {} 已被踢出:{}", sessionId, reason);
}
public void ban(String userId, Instant until, String reason) {
UserAccount account = requireUser(userId);
account.setStatus(until == null ? AccountStatus.BANNED : AccountStatus.SUSPENDED);
account.setStatusUntil(until);
account.setStatusReason(reason);
userStore.save(account);
revokeAllSessions(userId, "账号已被封禁" + (reason == null ? "" : "" + reason));
logger.info("账号 {} 已被封禁until={}, reason={}", userId, until, reason);
auditService.record("ADMIN", null, AuditService.ACTION_BAN, userId,
AuditService.RESULT_SUCCESS, null,
(until == null ? "永久封禁" : "临时封禁至 " + until) + ";原因:" + reason);
}
public void unban(String userId) {
UserAccount account = requireUser(userId);
account.setStatus(AccountStatus.ACTIVE);
account.setStatusUntil(null);
account.setStatusReason(null);
account.setFailedAttempts(0);
account.setLockedUntil(null);
userStore.save(account);
logger.info("账号 {} 已解封", userId);
auditService.record("ADMIN", null, AuditService.ACTION_UNBAN, userId,
AuditService.RESULT_SUCCESS, null, "账号已解封");
}
/**
* 修改密码。
*
* <p>校验原密码、密码强度,并禁止复用最近
* {@value com.ttstd.signaling.model.UserAccount#PASSWORD_HISTORY_SIZE} 个密码。
* 成功后吊销全部会话,强制重新登录。
*/
public void changePassword(String userId, String oldPassword, String newPassword) {
UserAccount account = requireUser(userId);
if (!passwordEncoder.matches(oldPassword, account.getPasswordHash())) {
auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED,
AuditService.RESULT_FAILURE, null, "原密码校验失败");
throw AuthException.unauthorized("原密码不正确");
}
validatePassword(newPassword, account.getUsername());
if (passwordEncoder.matches(newPassword, account.getPasswordHash())) {
throw AuthException.badRequest("新密码不能与当前密码相同");
}
for (String historical : account.passwordHistoryList()) {
if (passwordEncoder.matches(newPassword, historical)) {
throw AuthException.badRequest("新密码不能与最近使用过的密码相同");
}
}
// 先把旧密码存入历史,再更新为新密码
account.pushPasswordHistory(account.getPasswordHash());
account.setPasswordHash(passwordEncoder.encode(newPassword));
account.setPasswordChangedAt(Instant.now());
userStore.save(account);
revokeAllSessions(userId, "密码已修改,请重新登录");
auditService.recordUser(userId, AuditService.ACTION_PASSWORD_CHANGED,
AuditService.RESULT_SUCCESS, null, "密码修改成功,已吊销全部会话");
}
// ==================== TOTP 双因子 ====================
/**
* 生成 TOTP 密钥并返回 otpauth URI。此时尚未启用需调用
* {@link #enableTotp(String, String)} 输入一次动态码完成绑定。
*/
public Map<String, String> setupTotp(String userId) {
UserAccount account = requireUser(userId);
if (account.isTotpEnabled()) {
throw AuthException.badRequest("双因子认证已启用,请先关闭再重新绑定");
}
String secret = totpService.generateSecret();
account.setTotpSecret(secret);
account.setTotpEnabled(false);
userStore.save(account);
String uri = totpService.buildOtpAuthUri(
properties.getJwt().getIssuer(), account.getUsername(), secret);
return Map.of("secret", secret, "otpauthUri", uri);
}
/** 校验一次动态码并正式启用 TOTP。 */
public void enableTotp(String userId, String code) {
UserAccount account = requireUser(userId);
if (account.getTotpSecret() == null || account.getTotpSecret().isBlank()) {
throw AuthException.badRequest("请先调用 setup 生成密钥");
}
if (account.isTotpEnabled()) {
throw AuthException.badRequest("双因子认证已启用");
}
if (!totpService.verify(account.getTotpSecret(), code)) {
auditService.recordUser(userId, AuditService.ACTION_TOTP_FAILED,
AuditService.RESULT_FAILURE, null, "启用时动态码校验失败");
throw AuthException.badRequest("动态码不正确");
}
account.setTotpEnabled(true);
userStore.save(account);
auditService.recordUser(userId, AuditService.ACTION_TOTP_ENABLED,
AuditService.RESULT_SUCCESS, null, "双因子认证已启用");
}
/** 关闭 TOTP需同时校验密码与动态码防止令牌被盗后被单方面关闭。 */
public void disableTotp(String userId, String password, String code) {
UserAccount account = requireUser(userId);
if (!account.isTotpEnabled()) {
throw AuthException.badRequest("双因子认证未启用");
}
if (!passwordEncoder.matches(password, account.getPasswordHash())) {
throw AuthException.unauthorized("密码不正确");
}
if (!totpService.verify(account.getTotpSecret(), code)) {
throw AuthException.badRequest("动态码不正确");
}
account.setTotpEnabled(false);
account.setTotpSecret(null);
userStore.save(account);
auditService.recordUser(userId, AuditService.ACTION_TOTP_DISABLED,
AuditService.RESULT_SUCCESS, null, "双因子认证已关闭");
}
private void notifyRevoked(String principalId, String sessionId, String reason) {
SessionRevocationListener listener = this.revocationListener;
if (listener != null) {
try {
listener.onRevoked(principalId, sessionId, reason);
} catch (Exception e) {
logger.warn("下发会话失效通知失败: {}", e.getMessage());
}
}
}
// ==================== 查询 ====================
public UserAccount requireUser(String userId) {
return userStore.findByUserId(userId).orElseThrow(
() -> AuthException.badRequest("账号不存在"));
}
public List<UserAccount> listUsers() {
return userStore.listAll();
}
public List<LoginSession> listSessions(String userId) {
return sessionStore.listByPrincipal(userId).stream()
.filter(LoginSession::isActive)
.sorted(Comparator.comparing(LoginSession::getCreatedAt).reversed())
.toList();
}
/** 清理刷新令牌已过期或已吊销的会话记录(周期性维护)。 */
public void evictExpiredSessions() {
Instant now = Instant.now();
for (LoginSession s : sessionStore.listAll()) {
if (s.isRevoked() || s.getRefreshExpiresAt().isBefore(now)) {
sessionStore.delete(s.getSessionId());
}
}
}
}

View File

@@ -0,0 +1,139 @@
package com.ttstd.signaling.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
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} 表。
*
* <p>写入前会对敏感信息做脱敏,避免日志泄露凭据。
*/
@Service
public class AuditService {
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
private static final int MAX_ENTRIES = 5000;
/** 审计动作常量 */
public static final String ACTION_LOGIN = "LOGIN";
public static final String ACTION_LOGIN_FAILED = "LOGIN_FAILED";
public static final String ACTION_LOGOUT = "LOGOUT";
public static final String ACTION_REGISTER = "REGISTER";
public static final String ACTION_REFRESH = "REFRESH";
public static final String ACTION_REFRESH_REUSE = "REFRESH_TOKEN_REUSE";
public static final String ACTION_PASSWORD_CHANGED = "PASSWORD_CHANGED";
public static final String ACTION_ACCOUNT_LOCKED = "ACCOUNT_LOCKED";
public static final String ACTION_TOTP_ENABLED = "TOTP_ENABLED";
public static final String ACTION_TOTP_DISABLED = "TOTP_DISABLED";
public static final String ACTION_TOTP_FAILED = "TOTP_FAILED";
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_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_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_OFFER_BLOCKED = "OFFER_BLOCKED";
public static final String ACTION_PAIRING_GENERATED = "PAIRING_GENERATED";
public static final String ACTION_PAIRING_REDEEMED = "PAIRING_REDEEMED";
public static final String RESULT_SUCCESS = "SUCCESS";
public static final String RESULT_FAILURE = "FAILURE";
/**
* 单条审计记录。
*/
public record AuditEntry(
long id,
String actorType,
String actorId,
String action,
String targetId,
String result,
String ip,
String detail,
long timestamp) {
}
private final Deque<AuditEntry> entries = new ConcurrentLinkedDeque<>();
private final AtomicLong sequence = new AtomicLong();
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();
}
auditLogger.info("actor={}:{} action={} target={} result={} ip={} detail={}",
actorType, actorId, action, targetId, result, ip, entry.detail());
}
public void recordUser(String userId, String action, String result, String ip, String detail) {
record("USER", userId, action, null, result, ip, detail);
}
public void recordDevice(String deviceUid, String action, String result, String ip, String detail) {
record("DEVICE", deviceUid, action, null, result, ip, detail);
}
public void recordAdmin(String adminId, String action, String targetId, String detail) {
record("ADMIN", adminId, action, targetId, RESULT_SUCCESS, null, detail);
}
/** 查询最近的审计记录。 */
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;
}
/** 按主体过滤审计记录。 */
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 result;
}
private static String truncate(String detail) {
if (detail == null) {
return null;
}
return detail.length() > 512 ? detail.substring(0, 512) : detail;
}
}

View File

@@ -0,0 +1,159 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.DeviceBlacklistEntry;
import com.ttstd.signaling.repository.UserAccountRepository;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.TokenUtils;
import com.ttstd.signaling.store.BindingStore;
import com.ttstd.signaling.store.BlacklistStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 绑定关系与黑名单服务。
*
* <p>职责:
* <ul>
* <li>维护主控端账号与被控端设备的绑定关系({@link DeviceBinding}
* <li>维护被控端黑名单({@link DeviceBlacklistEntry}
* <li>为信令链路提供 <b>是否允许连接</b> 的判定:仅 ACTIVE 绑定且未被拉黑的主控端可发起 OFFER
* <li>承载被控端自助管理(拉黑/解绑)与主控端自助查询。
* </ul>
*
* <p>判定优先级:黑名单 > 绑定。即即便存在绑定,被拉黑后 OFFER 仍被服务端拒绝。
*/
@Service
public class BindingService {
private static final Logger logger = LoggerFactory.getLogger(BindingService.class);
private final BindingStore bindingStore;
private final BlacklistStore blacklistStore;
private final UserAccountRepository userAccountRepository;
private final AuditService auditService;
public BindingService(BindingStore bindingStore, BlacklistStore blacklistStore,
UserAccountRepository userAccountRepository, AuditService auditService) {
this.bindingStore = bindingStore;
this.blacklistStore = blacklistStore;
this.userAccountRepository = userAccountRepository;
this.auditService = auditService;
}
// ==================== 绑定 ====================
/**
* 创建或恢复绑定关系。若已存在(含 REVOKED则复用并更新为 ACTIVE。
*
* @return 绑定记录
*/
public DeviceBinding bind(String deviceUid, String userId, DeviceBinding.BindingRole role,
String alias, String boundBy) {
DeviceBinding existing = bindingStore.findByDeviceUidAndUserId(deviceUid, userId).orElse(null);
if (existing != null) {
existing.setStatus(DeviceBinding.BindingStatus.ACTIVE);
existing.setRole(role);
if (alias != null) {
existing.setAlias(alias);
}
existing.setBoundBy(boundBy);
DeviceBinding saved = bindingStore.save(existing);
logger.info("绑定恢复/更新: device={} user={} role={}", deviceUid, userId, role);
auditService.recordDevice(deviceUid, AuditService.ACTION_BIND, AuditService.RESULT_SUCCESS,
null, "user=" + userId + " role=" + role);
return saved;
}
DeviceBinding binding = new DeviceBinding(
"bind_" + TokenUtils.randomBase62(20), deviceUid, userId, role, alias, boundBy);
DeviceBinding saved = bindingStore.save(binding);
logger.info("绑定建立: device={} user={} binding={} role={}", deviceUid, userId,
saved.getBindingId(), role);
auditService.recordDevice(deviceUid, AuditService.ACTION_BIND, AuditService.RESULT_SUCCESS,
null, "user=" + userId + " role=" + role);
return saved;
}
/** 解绑(软删除:置为 REVOKED。 */
public void revokeBinding(String deviceUid, String userId, String actor) {
DeviceBinding binding = bindingStore.findByDeviceUidAndUserId(deviceUid, userId).orElse(null);
if (binding == null) {
return;
}
binding.setStatus(DeviceBinding.BindingStatus.REVOKED);
bindingStore.save(binding);
logger.info("解绑: device={} user={} by={}", deviceUid, userId, actor);
auditService.recordDevice(deviceUid, AuditService.ACTION_UNBIND, AuditService.RESULT_SUCCESS,
null, "user=" + userId + " by=" + actor);
}
/** 主控端是否对该设备拥有 ACTIVE 绑定。 */
public boolean isBound(String deviceUid, String userId) {
return bindingStore.findByDeviceUidAndUserId(deviceUid, userId)
.map(DeviceBinding::isActive)
.orElse(false);
}
public List<DeviceBinding> listByUser(String userId) {
return bindingStore.listByUserId(userId);
}
public List<DeviceBinding> listByDevice(String deviceUid) {
return bindingStore.listByDeviceUid(deviceUid);
}
// ==================== 黑名单 ====================
/** 被控端拉黑某主控端账号。 */
public DeviceBlacklistEntry addBlacklist(String deviceUid, String blockedUserId, String reason,
String actor) {
if (blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId)) {
DeviceBlacklistEntry existing = blacklistStore
.findByDeviceUidAndBlockedUserId(deviceUid, blockedUserId).orElseThrow();
existing.setReason(reason);
return blacklistStore.save(existing);
}
DeviceBlacklistEntry entry = new DeviceBlacklistEntry(deviceUid, blockedUserId, reason);
DeviceBlacklistEntry saved = blacklistStore.save(entry);
logger.info("拉黑: device={} blockedUser={} by={}", deviceUid, blockedUserId, actor);
auditService.recordDevice(deviceUid, AuditService.ACTION_BLACKLIST_ADD, AuditService.RESULT_SUCCESS,
null, "blockedUser=" + blockedUserId + " by=" + actor);
return saved;
}
public void removeBlacklist(String deviceUid, String blockedUserId, String actor) {
if (!blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId)) {
return;
}
blacklistStore.delete(deviceUid, blockedUserId);
logger.info("移除黑名单: device={} blockedUser={} by={}", deviceUid, blockedUserId, actor);
auditService.recordDevice(deviceUid, AuditService.ACTION_BLACKLIST_REMOVE,
AuditService.RESULT_SUCCESS, null, "blockedUser=" + blockedUserId + " by=" + actor);
}
/** 该主控端是否被设备拉黑。 */
public boolean isBlacklisted(String deviceUid, String userId) {
return blacklistStore.existsByDeviceUidAndBlockedUserId(deviceUid, userId);
}
public List<DeviceBlacklistEntry> listBlacklist(String deviceUid) {
return blacklistStore.listByDeviceUid(deviceUid);
}
// ==================== 用户名解析(被控端按用户名拉黑/绑定) ====================
/**
* 将用户名解析为用户 ID。失败抛出 {@link AuthException#badRequest}。
*/
public String resolveUserId(String username) {
if (username == null || username.isBlank()) {
throw AuthException.badRequest("用户名不能为空");
}
return userAccountRepository.findByUsername(username.trim())
.map(u -> u.getUserId())
.orElseThrow(() -> AuthException.badRequest("账号不存在: " + username));
}
}

View File

@@ -0,0 +1,281 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.AccountStatus;
import com.ttstd.signaling.model.AuthPrincipal;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceAllowlist;
import com.ttstd.signaling.model.DeviceType;
import com.ttstd.signaling.model.PrincipalType;
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.DeviceStore;
import com.ttstd.signaling.store.NonceStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 被控端设备身份服务。
*
* <p>被控端为系统签名应用、无法登录账号因此采用「SN + 内置共享密钥 HMAC」完成首次激活
* 激活后换取长期 {@code deviceSecret},再由 deviceSecret 换取短期访问令牌。
*
* <p>底层存储通过 {@link DeviceStore}/{@link NonceStore} 抽象,生产环境使用 MySQL/JPA + Redis
* 测试/演示使用内存实现,调用方无感知。
*/
@Service
public class DeviceIdentityService {
private static final Logger logger = LoggerFactory.getLogger(DeviceIdentityService.class);
private final DeviceStore deviceStore;
private final NonceStore nonceStore;
private final SecurityProperties properties;
private final JwtService jwtService;
private volatile AccountService.SessionRevocationListener revocationListener;
public DeviceIdentityService(DeviceStore deviceStore,
NonceStore nonceStore,
SecurityProperties properties,
JwtService jwtService) {
this.deviceStore = deviceStore;
this.nonceStore = nonceStore;
this.properties = properties;
this.jwtService = jwtService;
}
public void setRevocationListener(AccountService.SessionRevocationListener listener) {
this.revocationListener = listener;
}
/**
* 设备激活结果。deviceSecret 仅此一次返回明文。
*/
public record ProvisionResult(String deviceUid, String deviceSecret) {
}
// ==================== 激活 ====================
/**
* 首次激活:校验 SN 白名单、HMAC 签名、时间戳与 nonce签发 deviceSecret。
*
* <p>若设备已激活,则执行重新激活(轮换 deviceSecret 并吊销旧令牌)。
*/
public ProvisionResult provision(String sn, String model, String nonce, long timestamp, String hmac) {
if (sn == null || sn.isBlank()) {
throw AuthException.badRequest("SN 不能为空");
}
String secret = properties.getDevice().getProvisionSecret();
if (secret == null || secret.isBlank()) {
logger.error("未配置 security.device.provision-secret拒绝所有设备激活请求");
throw AuthException.forbidden("设备激活未启用", "provision secret not configured");
}
if (nonce == null || nonce.isBlank() || hmac == null || hmac.isBlank()) {
throw AuthException.badRequest("激活参数不完整");
}
long skew = properties.getDevice().getProvisionSkewSeconds();
long now = Instant.now().getEpochSecond();
if (Math.abs(now - timestamp) > skew) {
throw AuthException.forbidden("激活请求已过期", "provision timestamp out of window, sn="
+ DeviceAccount.maskSn(sn));
}
// nonce 唯一性校验(防重放)
if (!nonceStore.tryReserve(nonce, skew * 2)) {
throw AuthException.forbidden("激活请求重复", "nonce replayed, sn=" + DeviceAccount.maskSn(sn));
}
String expected = hmacHex(secret, sn + "|" + nonce + "|" + timestamp);
if (!MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
hmac.toLowerCase().getBytes(StandardCharsets.UTF_8))) {
throw AuthException.forbidden("激活签名校验失败",
"provision hmac mismatch, sn=" + DeviceAccount.maskSn(sn));
}
if (properties.getDevice().isSnAllowlistEnabled() && !deviceStore.isSnAllowed(sn)) {
throw AuthException.forbidden("设备未授权",
"sn not in allowlist: " + DeviceAccount.maskSn(sn));
}
String deviceSecret = TokenUtils.randomSecret(32);
String secretHash = TokenUtils.sha256(deviceSecret);
DeviceAccount existing = deviceStore.findBySn(sn).orElse(null);
if (existing != null) {
if (existing.getStatus() == AccountStatus.BANNED) {
throw AuthException.forbidden("设备已被禁用",
"banned device re-provision attempt: " + existing.maskedSn());
}
existing.setSecretHash(secretHash);
existing.setModel(model);
existing.setTokenVersion(existing.getTokenVersion() + 1);
deviceStore.save(existing);
notifyRevoked(existing.getDeviceUid(), "设备已重新激活,旧凭据失效");
logger.warn("设备重新激活: uid={} sn={}", existing.getDeviceUid(), existing.maskedSn());
return new ProvisionResult(existing.getDeviceUid(), deviceSecret);
}
String deviceUid = "dev_" + TokenUtils.randomBase62(22);
DeviceAccount device = new DeviceAccount(deviceUid, sn, secretHash, model);
deviceStore.save(device);
logger.info("设备已激活: uid={} sn={} model={}", deviceUid, device.maskedSn(), model);
return new ProvisionResult(deviceUid, deviceSecret);
}
// ==================== 换取令牌 ====================
public TokenPair issueDeviceToken(String deviceUid, String deviceSecret) {
DeviceAccount device = deviceUid == null ? null : deviceStore.findByDeviceUid(deviceUid).orElse(null);
if (device == null || deviceSecret == null) {
throw AuthException.unauthorized("设备凭据无效");
}
if (!TokenUtils.constantTimeEquals(device.getSecretHash(), TokenUtils.sha256(deviceSecret))) {
throw AuthException.unauthorized("设备凭据无效: " + deviceUid);
}
assertDeviceUsable(device);
Map<String, Object> claims = jwtService.newClaims();
claims.put("typ", PrincipalType.DEVICE.name());
claims.put("model", device.getModel());
String token = jwtService.issue(device.getDeviceUid(), JwtService.PURPOSE_DEVICE,
properties.getJwt().getDeviceTokenTtlSeconds(),
device.getTokenVersion(), claims);
device.setLastOnlineAt(Instant.now());
deviceStore.save(device);
return new TokenPair(token, null,
properties.getJwt().getDeviceTokenTtlSeconds(),
null, device.getDeviceUid(), device.getModel());
}
public AuthPrincipal authenticate(String token) {
Map<String, Object> claims = jwtService.verify(token, JwtService.PURPOSE_DEVICE);
if (!PrincipalType.DEVICE.name().equals(JwtService.claimAsString(claims, "typ"))) {
throw AuthException.unauthorized("令牌主体类型不匹配");
}
String deviceUid = JwtService.claimAsString(claims, "sub");
DeviceAccount device = deviceStore.findByDeviceUid(deviceUid).orElse(null);
if (device == null) {
throw AuthException.unauthorized("设备不存在: " + deviceUid);
}
long ver = JwtService.claimAsLong(claims, "ver", -1);
if (ver != device.getTokenVersion()) {
throw AuthException.unauthorized("设备令牌已失效(凭据版本变更)");
}
assertDeviceUsable(device);
device.setLastOnlineAt(Instant.now());
deviceStore.save(device);
return new AuthPrincipal(PrincipalType.DEVICE, deviceUid, deviceUid,
DeviceType.CONTROLLED, null, device.getModel(), false);
}
private void assertDeviceUsable(DeviceAccount device) {
if (device.getStatus() == AccountStatus.SUSPENDED
&& device.getStatusUntil() != null
&& Instant.now().isAfter(device.getStatusUntil())) {
device.setStatus(AccountStatus.ACTIVE);
device.setStatusUntil(null);
device.setStatusReason(null);
deviceStore.save(device);
}
if (!device.isUsable()) {
throw AuthException.forbidden("设备已被禁用",
"device not usable: " + device.getDeviceUid());
}
}
// ==================== 管理 ====================
public void disable(String deviceUid, Instant until, String reason) {
DeviceAccount device = requireDevice(deviceUid);
device.setStatus(until == null ? AccountStatus.BANNED : AccountStatus.SUSPENDED);
device.setStatusUntil(until);
device.setStatusReason(reason);
device.setTokenVersion(device.getTokenVersion() + 1);
deviceStore.save(device);
notifyRevoked(deviceUid, "设备已被禁用" + (reason == null ? "" : "" + reason));
logger.info("设备 {} 已被禁用until={}, reason={}", deviceUid, until, reason);
}
public void enable(String deviceUid) {
DeviceAccount device = requireDevice(deviceUid);
device.setStatus(AccountStatus.ACTIVE);
device.setStatusUntil(null);
device.setStatusReason(null);
deviceStore.save(device);
logger.info("设备 {} 已启用", deviceUid);
}
public DeviceAccount requireDevice(String deviceUid) {
return deviceStore.findByDeviceUid(deviceUid)
.orElseThrow(() -> AuthException.badRequest("设备不存在"));
}
public DeviceAccount findByUid(String deviceUid) {
return deviceUid == null ? null : deviceStore.findByDeviceUid(deviceUid).orElse(null);
}
public List<DeviceAccount> listDevices() {
return new ArrayList<>(deviceStore.listAll());
}
/** 批量导入 SN 白名单(持久化到 MySQL。 */
public int importAllowlist(List<String> sns) {
int added = 0;
String importedBy = "admin";
for (String sn : sns) {
if (sn == null || sn.isBlank()) {
continue;
}
String trimmed = sn.trim();
if (!deviceStore.isSnAllowed(trimmed)) {
deviceStore.addAllowedSn(new DeviceAllowlist(trimmed, "manual", importedBy));
added++;
}
}
logger.info("SN 白名单导入 {} 条,当前总数 {}", added, deviceStore.listAllowed().size());
return added;
}
public Set<String> getAllowlist() {
return Set.copyOf(deviceStore.listAllowed().stream().map(DeviceAllowlist::getSn).toList());
}
private void notifyRevoked(String deviceUid, String reason) {
AccountService.SessionRevocationListener listener = this.revocationListener;
if (listener != null) {
try {
listener.onRevoked(deviceUid, null, reason);
} catch (Exception e) {
logger.warn("下发设备失效通知失败: {}", e.getMessage());
}
}
}
private static String hmacHex(String key, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("HMAC 计算失败", e);
}
}
}

View File

@@ -0,0 +1,110 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.model.PairingCode;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.TokenUtils;
import com.ttstd.signaling.store.PairingStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
/**
* 配对码服务:建立「被控端 → 主控端」绑定的用户友好入口。
*
* <p>被控端调用 {@link #generate(String)} 生成一次性配对码(明文仅回显一次,存 SHA-256 摘要);
* 主控端输入配对码调用 {@link #redeem(String, String)},服务端校验后创建绑定关系。
*
* <p>防护:配对码有时效(默认 10 分钟)、单次使用、错误尝试上限(默认 5 次,超限即失效),
* 且摘要存储,避免明文泄露与离线爆破。
*/
@Service
public class PairingService {
private static final Logger logger = LoggerFactory.getLogger(PairingService.class);
private static final int MAX_ATTEMPTS = 5;
private static final int CODE_LENGTH = 8;
private final PairingStore pairingStore;
private final BindingService bindingService;
private final AuditService auditService;
private final long ttlSeconds;
public PairingService(PairingStore pairingStore,
BindingService bindingService,
AuditService auditService,
@Value("${security.pairing.ttl-seconds:600}") long ttlSeconds) {
this.pairingStore = pairingStore;
this.bindingService = bindingService;
this.auditService = auditService;
this.ttlSeconds = ttlSeconds;
}
/** 生成配对码,返回明文(仅此一次)。 */
public String generate(String deviceUid) {
if (deviceUid == null || deviceUid.isBlank()) {
throw AuthException.badRequest("设备标识不能为空");
}
// 去除易混淆字符0/O/1/I/l仅保留 base32 风格字符
String code = randomReadableCode(CODE_LENGTH);
String hash = TokenUtils.sha256(code);
Instant expiresAt = Instant.now().plusSeconds(ttlSeconds);
PairingCode pc = new PairingCode(hash, deviceUid, expiresAt);
pairingStore.save(pc);
logger.info("生成配对码: device={} ttl={}s", deviceUid, ttlSeconds);
auditService.recordDevice(deviceUid, AuditService.ACTION_PAIRING_GENERATED,
AuditService.RESULT_SUCCESS, null, "ttl=" + ttlSeconds);
return code;
}
/**
* 兑换配对码:校验通过后创建绑定。返回绑定记录。
*/
public DeviceBinding redeem(String code, String userId) {
if (code == null || code.isBlank() || userId == null) {
throw AuthException.badRequest("配对码不能为空");
}
String normalized = code.trim().toUpperCase();
String hash = TokenUtils.sha256(normalized);
PairingCode pc = pairingStore.findByCodeHash(hash)
.orElseThrow(() -> AuthException.badRequest("配对码无效"));
if (pc.isUsed()) {
throw AuthException.badRequest("配对码已被使用");
}
if (pc.isExpired()) {
pairingStore.delete(hash);
throw AuthException.badRequest("配对码已过期");
}
if (pc.getAttempts() >= MAX_ATTEMPTS) {
pairingStore.delete(hash);
throw AuthException.badRequest("配对码尝试次数过多,已失效");
}
// 校验通过:标记已用并创建绑定(主控端 MEMBER 角色)
pc.setUsed(true);
pairingStore.save(pc);
DeviceBinding binding = bindingService.bind(
pc.getDeviceUid(), userId, DeviceBinding.BindingRole.MEMBER, null,
"pairing:" + pc.getDeviceUid());
logger.info("配对码兑换成功: device={} user={} binding={}", pc.getDeviceUid(), userId,
binding.getBindingId());
auditService.recordUser(userId, AuditService.ACTION_PAIRING_REDEEMED,
AuditService.RESULT_SUCCESS, null, "device=" + pc.getDeviceUid());
return binding;
}
private static final java.security.SecureRandom RND = new java.security.SecureRandom();
private static String randomReadableCode(int length) {
// base32 字符集(去除了易混淆的 0/O/1/I/L
final String alphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(alphabet.charAt(RND.nextInt(alphabet.length())));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,31 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.security.RateLimiter;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 安全相关内存结构的周期性清理,防止长期运行造成内存增长。
*/
@Component
public class SecurityMaintenanceTask {
private final AccountService accountService;
private final DeviceIdentityService deviceIdentityService;
private final RateLimiter rateLimiter;
public SecurityMaintenanceTask(AccountService accountService,
DeviceIdentityService deviceIdentityService,
RateLimiter rateLimiter) {
this.accountService = accountService;
this.deviceIdentityService = deviceIdentityService;
this.rateLimiter = rateLimiter;
}
/** 每 5 分钟清理一次过期会话与限流窗口nonce 由 Redis 自动过期)。 */
@Scheduled(fixedDelay = 300_000L, initialDelay = 300_000L)
public void cleanup() {
accountService.evictExpiredSessions();
rateLimiter.evictExpired();
}
}

View File

@@ -0,0 +1,20 @@
package com.ttstd.signaling.service;
/**
* 登录/刷新返回的令牌对。
*
* @param accessToken 短期访问令牌(用于 HTTP 接口与 WebSocket 握手)
* @param refreshToken 刷新令牌(一次性,使用后轮转)
* @param expiresInSeconds 访问令牌剩余有效期
* @param sessionId 登录会话 ID
* @param principalId 主体 ID
* @param displayName 展示名
*/
public record TokenPair(
String accessToken,
String refreshToken,
long expiresInSeconds,
String sessionId,
String principalId,
String displayName) {
}

View File

@@ -0,0 +1,111 @@
package com.ttstd.signaling.service;
import com.ttstd.signaling.security.AuthException;
import com.ttstd.signaling.security.SecurityProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* TURN 短期凭证服务RFC 7635 风格)。
*
* <p>客户端在发起/接受连接前,向本服务换取一组有效期有限的 TURN 凭证:
* <ul>
* <li>{@code username} 形如 {@code <expSeconds>:<random>},过期时间编码其中;</li>
* <li>{@code credential} 为 {@code HMAC-SHA1(sharedSecret, username)} 的 Base64</li>
* <li>凭证到期需重新获取,避免长时间有效的静态密钥泄露风险。</li>
* </ul>
*
* <p>返回的 {@code iceServers} 可直接用于 WebRTC 的 {@code RTCPeerConnection} 配置。
*/
@Service
public class TurnCredentialService {
private static final Logger logger = LoggerFactory.getLogger(TurnCredentialService.class);
private static final Base64.Encoder B64 = Base64.getEncoder();
private final SecurityProperties properties;
public TurnCredentialService(SecurityProperties properties) {
this.properties = properties;
}
/** 是否已配置并可发放 TURN 凭证。 */
public boolean isEnabled() {
return properties.getTurn().isEnabled()
&& properties.getTurn().getSharedSecret() != null
&& !properties.getTurn().getSharedSecret().isBlank()
&& properties.getTurn().getUrls() != null
&& !properties.getTurn().getUrls().isBlank();
}
/**
* 生成一组 TURN 短期凭证。
*
* @param scope 用途提示(如 "controller:" + bindingId仅用于审计/日志,不影响计算
* @return 包含 iceServers 的响应体
*/
public Map<String, Object> issue(String scope) {
if (!isEnabled()) {
throw AuthException.unauthorized("TURN 凭证服务未启用");
}
SecurityProperties.Turn turn = properties.getTurn();
long ttl = turn.getTtlSeconds();
long expSeconds = Instant.now().getEpochSecond() + ttl;
String username = expSeconds + ":" + randomToken(12);
String credential = hmacSha1(turn.getSharedSecret(), username);
List<Map<String, String>> iceServers = new ArrayList<>();
for (String rawUrl : turn.getUrls().split(",")) {
String url = rawUrl.trim();
if (url.isEmpty()) {
continue;
}
Map<String, String> server = new LinkedHashMap<>();
server.put("urls", url);
server.put("username", username);
server.put("credential", credential);
iceServers.add(server);
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("iceServers", iceServers);
response.put("expiresAt", expSeconds);
response.put("ttlSeconds", ttl);
logger.debug("发放 TURN 凭证: scope={} exp={}", scope, expSeconds);
return response;
}
private static String hmacSha1(String key, String data) {
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return B64.encodeToString(raw);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException("TURN 凭证计算失败", e);
}
}
private static String randomToken(int length) {
java.security.SecureRandom rnd = new java.security.SecureRandom();
final String alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(alphabet.charAt(rnd.nextInt(alphabet.length())));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,22 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBinding;
import java.util.List;
import java.util.Optional;
/**
* 绑定关系存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。
*/
public interface BindingStore {
DeviceBinding save(DeviceBinding binding);
Optional<DeviceBinding> findByDeviceUidAndUserId(String deviceUid, String userId);
List<DeviceBinding> listByDeviceUid(String deviceUid);
List<DeviceBinding> listByUserId(String userId);
void delete(String bindingId);
}

View File

@@ -0,0 +1,22 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBlacklistEntry;
import java.util.List;
import java.util.Optional;
/**
* 被控端黑名单存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。
*/
public interface BlacklistStore {
DeviceBlacklistEntry save(DeviceBlacklistEntry entry);
Optional<DeviceBlacklistEntry> findByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId);
boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId);
List<DeviceBlacklistEntry> listByDeviceUid(String deviceUid);
void delete(String deviceUid, String blockedUserId);
}

View File

@@ -0,0 +1,22 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceAllowlist;
import java.util.List;
import java.util.Optional;
/**
* 设备身份存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。
*/
public interface DeviceStore {
Optional<DeviceAccount> findByDeviceUid(String deviceUid);
Optional<DeviceAccount> findBySn(String sn);
boolean existsBySn(String sn);
DeviceAccount save(DeviceAccount account);
List<DeviceAccount> listAll();
boolean isSnAllowed(String sn);
void addAllowedSn(DeviceAllowlist entry);
List<DeviceAllowlist> listAllowed();
}

View File

@@ -0,0 +1,51 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBinding;
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 InMemoryBindingStore implements BindingStore {
private final ConcurrentMap<String, DeviceBinding> byId = new ConcurrentHashMap<>();
@Override
public synchronized DeviceBinding save(DeviceBinding binding) {
byId.put(binding.getBindingId(), binding);
return binding;
}
@Override
public synchronized Optional<DeviceBinding> findByDeviceUidAndUserId(String deviceUid, String userId) {
return byId.values().stream()
.filter(b -> b.getDeviceUid().equals(deviceUid) && b.getUserId().equals(userId))
.findFirst();
}
@Override
public synchronized List<DeviceBinding> listByDeviceUid(String deviceUid) {
return byId.values().stream()
.filter(b -> b.getDeviceUid().equals(deviceUid))
.collect(Collectors.toList());
}
@Override
public synchronized List<DeviceBinding> listByUserId(String userId) {
return byId.values().stream()
.filter(b -> b.getUserId().equals(userId))
.collect(Collectors.toList());
}
@Override
public synchronized void delete(String bindingId) {
byId.remove(bindingId);
}
}

View File

@@ -0,0 +1,52 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBlacklistEntry;
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 InMemoryBlacklistStore implements BlacklistStore {
private final ConcurrentMap<String, DeviceBlacklistEntry> entries = new ConcurrentHashMap<>();
private static String key(String deviceUid, String blockedUserId) {
return deviceUid + "\u0000" + blockedUserId;
}
@Override
public synchronized DeviceBlacklistEntry save(DeviceBlacklistEntry entry) {
entries.put(key(entry.getDeviceUid(), entry.getBlockedUserId()), entry);
return entry;
}
@Override
public synchronized Optional<DeviceBlacklistEntry> findByDeviceUidAndBlockedUserId(
String deviceUid, String blockedUserId) {
return Optional.ofNullable(entries.get(key(deviceUid, blockedUserId)));
}
@Override
public synchronized boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId) {
return entries.containsKey(key(deviceUid, blockedUserId));
}
@Override
public synchronized List<DeviceBlacklistEntry> listByDeviceUid(String deviceUid) {
return entries.values().stream()
.filter(e -> e.getDeviceUid().equals(deviceUid))
.collect(Collectors.toList());
}
@Override
public synchronized void delete(String deviceUid, String blockedUserId) {
entries.remove(key(deviceUid, blockedUserId));
}
}

View File

@@ -0,0 +1,65 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceAllowlist;
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 InMemoryDeviceStore implements DeviceStore {
private final ConcurrentMap<String, DeviceAccount> byUid = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> snToUid = new ConcurrentHashMap<>();
private final ConcurrentMap<String, DeviceAllowlist> allowlist = new ConcurrentHashMap<>();
@Override
public synchronized Optional<DeviceAccount> findByDeviceUid(String deviceUid) {
return Optional.ofNullable(byUid.get(deviceUid));
}
@Override
public synchronized Optional<DeviceAccount> findBySn(String sn) {
String uid = snToUid.get(sn);
return uid == null ? Optional.empty() : Optional.ofNullable(byUid.get(uid));
}
@Override
public synchronized boolean existsBySn(String sn) {
return snToUid.containsKey(sn);
}
@Override
public synchronized DeviceAccount save(DeviceAccount account) {
byUid.put(account.getDeviceUid(), account);
snToUid.put(account.getSn(), account.getDeviceUid());
return account;
}
@Override
public synchronized List<DeviceAccount> listAll() {
return List.copyOf(byUid.values());
}
@Override
public synchronized boolean isSnAllowed(String sn) {
return allowlist.containsKey(sn);
}
@Override
public synchronized void addAllowedSn(DeviceAllowlist entry) {
allowlist.put(entry.getSn(), entry);
}
@Override
public synchronized List<DeviceAllowlist> listAllowed() {
return List.copyOf(allowlist.values());
}
}

View File

@@ -0,0 +1,27 @@
package com.ttstd.signaling.store;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** 内存 nonce 去重实现,用于 storage.mode=memory 或 Redis 不可用时的降级。 */
@Component
@Profile("memory")
public class InMemoryNonceStore implements NonceStore {
private final Map<String, Instant> nonces = new ConcurrentHashMap<>();
@Override
public synchronized boolean tryReserve(String nonce, long ttlSeconds) {
Instant now = Instant.now();
nonces.entrySet().removeIf(e -> now.isAfter(e.getValue()));
if (nonces.containsKey(nonce)) {
return false;
}
nonces.put(nonce, now.plusSeconds(ttlSeconds));
return true;
}
}

View File

@@ -0,0 +1,33 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.PairingCode;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/** 内存实现用于测试与本地演示storage.mode=memory。 */
@Component
@Profile("memory")
public class InMemoryPairingStore implements PairingStore {
private final ConcurrentMap<String, PairingCode> byHash = new ConcurrentHashMap<>();
@Override
public synchronized PairingCode save(PairingCode code) {
byHash.put(code.getCodeHash(), code);
return code;
}
@Override
public synchronized Optional<PairingCode> findByCodeHash(String codeHash) {
return Optional.ofNullable(byHash.get(codeHash));
}
@Override
public synchronized void delete(String codeHash) {
byHash.remove(codeHash);
}
}

View File

@@ -0,0 +1,89 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.LoginSession;
import com.ttstd.signaling.model.PrincipalType;
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 InMemorySessionStore implements SessionStore {
private final ConcurrentMap<String, LoginSession> sessions = new ConcurrentHashMap<>();
@Override
public synchronized LoginSession save(LoginSession session) {
sessions.put(session.getSessionId(), session);
return session;
}
@Override
public synchronized Optional<LoginSession> findBySessionId(String sessionId) {
return Optional.ofNullable(sessions.get(sessionId));
}
@Override
public synchronized List<LoginSession> listByPrincipal(String principalId) {
return sessions.values().stream()
.filter(s -> s.getPrincipalId().equals(principalId))
.collect(Collectors.toList());
}
@Override
public synchronized List<LoginSession> listByPrincipalAndType(String principalId, PrincipalType type) {
return sessions.values().stream()
.filter(s -> s.getPrincipalId().equals(principalId) && s.getPrincipalType() == type)
.collect(Collectors.toList());
}
@Override
public synchronized List<LoginSession> listAll() {
return List.copyOf(sessions.values());
}
@Override
public synchronized void delete(String sessionId) {
sessions.remove(sessionId);
}
@Override
public synchronized void deleteByPrincipal(String principalId) {
sessions.values().removeIf(s -> s.getPrincipalId().equals(principalId));
}
@Override
public synchronized int revokeByPrincipal(String principalId) {
int n = 0;
for (LoginSession s : sessions.values()) {
if (s.getPrincipalId().equals(principalId)) {
s.setRevoked(true);
n++;
}
}
return n;
}
@Override
public synchronized int revokeBySessionId(String sessionId) {
LoginSession s = sessions.get(sessionId);
if (s != null) {
s.setRevoked(true);
return 1;
}
return 0;
}
@Override
public synchronized long countByPrincipalAndType(String principalId, PrincipalType type) {
return sessions.values().stream()
.filter(s -> s.getPrincipalId().equals(principalId) && s.getPrincipalType() == type)
.count();
}
}

View File

@@ -0,0 +1,48 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.UserAccount;
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 InMemoryUserStore implements UserStore {
private final ConcurrentMap<String, UserAccount> byId = new ConcurrentHashMap<>();
private final ConcurrentMap<String, String> usernameToId = new ConcurrentHashMap<>();
@Override
public synchronized Optional<UserAccount> findByUserId(String userId) {
return Optional.ofNullable(byId.get(userId));
}
@Override
public synchronized Optional<UserAccount> findByUsername(String username) {
String id = usernameToId.get(username);
return id == null ? Optional.empty() : Optional.ofNullable(byId.get(id));
}
@Override
public synchronized boolean existsByUsername(String username) {
return usernameToId.containsKey(username);
}
@Override
public synchronized UserAccount save(UserAccount account) {
byId.put(account.getUserId(), account);
usernameToId.put(account.getUsername(), account.getUserId());
return account;
}
@Override
public synchronized List<UserAccount> listAll() {
return List.copyOf(byId.values());
}
}

View File

@@ -0,0 +1,46 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBinding;
import com.ttstd.signaling.repository.DeviceBindingRepository;
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 JpaBindingStore implements BindingStore {
private final DeviceBindingRepository repository;
public JpaBindingStore(DeviceBindingRepository repository) {
this.repository = repository;
}
@Override
public DeviceBinding save(DeviceBinding binding) {
return repository.save(binding);
}
@Override
public Optional<DeviceBinding> findByDeviceUidAndUserId(String deviceUid, String userId) {
return repository.findByDeviceUidAndUserId(deviceUid, userId);
}
@Override
public List<DeviceBinding> listByDeviceUid(String deviceUid) {
return repository.findByDeviceUid(deviceUid);
}
@Override
public List<DeviceBinding> listByUserId(String userId) {
return repository.findByUserId(userId);
}
@Override
public void delete(String bindingId) {
repository.deleteById(bindingId);
}
}

View File

@@ -0,0 +1,47 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceBlacklistEntry;
import com.ttstd.signaling.repository.DeviceBlacklistRepository;
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 JpaBlacklistStore implements BlacklistStore {
private final DeviceBlacklistRepository repository;
public JpaBlacklistStore(DeviceBlacklistRepository repository) {
this.repository = repository;
}
@Override
public DeviceBlacklistEntry save(DeviceBlacklistEntry entry) {
return repository.save(entry);
}
@Override
public Optional<DeviceBlacklistEntry> findByDeviceUidAndBlockedUserId(
String deviceUid, String blockedUserId) {
return repository.findByDeviceUidAndBlockedUserId(deviceUid, blockedUserId);
}
@Override
public boolean existsByDeviceUidAndBlockedUserId(String deviceUid, String blockedUserId) {
return repository.existsByDeviceUidAndBlockedUserId(deviceUid, blockedUserId);
}
@Override
public List<DeviceBlacklistEntry> listByDeviceUid(String deviceUid) {
return repository.findByDeviceUid(deviceUid);
}
@Override
public void delete(String deviceUid, String blockedUserId) {
repository.deleteById(new DeviceBlacklistEntry.PK(deviceUid, blockedUserId));
}
}

View File

@@ -0,0 +1,66 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.DeviceAccount;
import com.ttstd.signaling.model.DeviceAllowlist;
import com.ttstd.signaling.repository.DeviceAccountRepository;
import com.ttstd.signaling.repository.DeviceAllowlistRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/** JPA 实现:设备身份与 SN 白名单持久化到 MySQL。仅在 storage.mode=jpa默认时生效。 */
@Component
@Profile("!memory")
public class JpaDeviceStore implements DeviceStore {
private final DeviceAccountRepository deviceRepository;
private final DeviceAllowlistRepository allowlistRepository;
public JpaDeviceStore(DeviceAccountRepository deviceRepository,
DeviceAllowlistRepository allowlistRepository) {
this.deviceRepository = deviceRepository;
this.allowlistRepository = allowlistRepository;
}
@Override
public Optional<DeviceAccount> findByDeviceUid(String deviceUid) {
return deviceRepository.findById(deviceUid);
}
@Override
public Optional<DeviceAccount> findBySn(String sn) {
return deviceRepository.findBySn(sn);
}
@Override
public boolean existsBySn(String sn) {
return deviceRepository.existsBySn(sn);
}
@Override
public DeviceAccount save(DeviceAccount account) {
return deviceRepository.save(account);
}
@Override
public List<DeviceAccount> listAll() {
return deviceRepository.findAll();
}
@Override
public boolean isSnAllowed(String sn) {
return allowlistRepository.existsBySn(sn);
}
@Override
public void addAllowedSn(DeviceAllowlist entry) {
allowlistRepository.save(entry);
}
@Override
public List<DeviceAllowlist> listAllowed() {
return allowlistRepository.findAll();
}
}

View File

@@ -0,0 +1,35 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.PairingCode;
import com.ttstd.signaling.repository.PairingCodeRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.Optional;
/** JPA 实现,配对码持久化到 MySQLstorage.mode=jpa默认。 */
@Component
@Profile("!memory")
public class JpaPairingStore implements PairingStore {
private final PairingCodeRepository repository;
public JpaPairingStore(PairingCodeRepository repository) {
this.repository = repository;
}
@Override
public PairingCode save(PairingCode code) {
return repository.save(code);
}
@Override
public Optional<PairingCode> findByCodeHash(String codeHash) {
return repository.findByCodeHash(codeHash);
}
@Override
public void delete(String codeHash) {
repository.deleteById(codeHash);
}
}

View File

@@ -0,0 +1,72 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.LoginSession;
import com.ttstd.signaling.model.PrincipalType;
import com.ttstd.signaling.repository.LoginSessionRepository;
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 JpaSessionStore implements SessionStore {
private final LoginSessionRepository repository;
public JpaSessionStore(LoginSessionRepository repository) {
this.repository = repository;
}
@Override
public LoginSession save(LoginSession session) {
return repository.save(session);
}
@Override
public Optional<LoginSession> findBySessionId(String sessionId) {
return repository.findById(sessionId);
}
@Override
public List<LoginSession> listByPrincipal(String principalId) {
return repository.findByPrincipalId(principalId);
}
@Override
public List<LoginSession> listByPrincipalAndType(String principalId, PrincipalType type) {
return repository.findByPrincipalIdAndPrincipalType(principalId, type);
}
@Override
public List<LoginSession> listAll() {
return repository.findAll();
}
@Override
public void delete(String sessionId) {
repository.deleteBySessionId(sessionId);
}
@Override
public void deleteByPrincipal(String principalId) {
repository.findByPrincipalId(principalId).forEach(s -> repository.delete(s));
}
@Override
public int revokeByPrincipal(String principalId) {
return repository.revokeByPrincipalId(principalId);
}
@Override
public int revokeBySessionId(String sessionId) {
return repository.revokeBySessionId(sessionId);
}
@Override
public long countByPrincipalAndType(String principalId, PrincipalType type) {
return repository.countByPrincipalIdAndPrincipalType(principalId, type);
}
}

View File

@@ -0,0 +1,46 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.UserAccount;
import com.ttstd.signaling.repository.UserAccountRepository;
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 JpaUserStore implements UserStore {
private final UserAccountRepository repository;
public JpaUserStore(UserAccountRepository repository) {
this.repository = repository;
}
@Override
public Optional<UserAccount> findByUserId(String userId) {
return repository.findById(userId);
}
@Override
public Optional<UserAccount> findByUsername(String username) {
return repository.findByUsername(username);
}
@Override
public boolean existsByUsername(String username) {
return repository.existsByUsername(username);
}
@Override
public UserAccount save(UserAccount account) {
return repository.save(account);
}
@Override
public List<UserAccount> listAll() {
return repository.findAll();
}
}

View File

@@ -0,0 +1,14 @@
package com.ttstd.signaling.store;
/**
* 一次性 nonce 去重存储(防重放)。优先使用 Redis多实例共享不可用时降级为内存。
*/
public interface NonceStore {
/**
* 若 nonce 不存在则记录并返回 true已存在则返回 false。
*
* @param nonce 待校验的随机串
* @param ttlSeconds 过期秒数
*/
boolean tryReserve(String nonce, long ttlSeconds);
}

View File

@@ -0,0 +1,14 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.PairingCode;
import java.util.Optional;
/** 配对码存储抽象。 */
public interface PairingStore {
PairingCode save(PairingCode code);
Optional<PairingCode> findByCodeHash(String codeHash);
void delete(String codeHash);
}

View File

@@ -0,0 +1,33 @@
package com.ttstd.signaling.store;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 基于 Redis 的 nonce 去重实现(多实例共享,推荐生产使用)。
*
* <p>通过 {@code SET key value NX EX ttl} 原子操作保证并发安全Redis 不可用时
* 调用方应降级到 {@link InMemoryNonceStore}(由配置选择),本类不内嵌降级逻辑。
*/
@Component
@org.springframework.context.annotation.Profile("!memory")
public class RedisNonceStore implements NonceStore {
private static final Logger logger = LoggerFactory.getLogger(RedisNonceStore.class);
private final StringRedisTemplate redis;
public RedisNonceStore(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
public boolean tryReserve(String nonce, long ttlSeconds) {
String key = "webrtc:nonce:" + nonce;
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", ttlSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok);
}
}

View File

@@ -0,0 +1,23 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.LoginSession;
import com.ttstd.signaling.model.PrincipalType;
import java.util.List;
import java.util.Optional;
/**
* 登录会话存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。
*/
public interface SessionStore {
LoginSession save(LoginSession session);
Optional<LoginSession> findBySessionId(String sessionId);
List<LoginSession> listByPrincipal(String principalId);
List<LoginSession> listByPrincipalAndType(String principalId, PrincipalType type);
List<LoginSession> listAll();
void delete(String sessionId);
void deleteByPrincipal(String principalId);
int revokeByPrincipal(String principalId);
int revokeBySessionId(String sessionId);
long countByPrincipalAndType(String principalId, PrincipalType type);
}

View File

@@ -0,0 +1,17 @@
package com.ttstd.signaling.store;
import com.ttstd.signaling.model.UserAccount;
import java.util.List;
import java.util.Optional;
/**
* 用户账号存储抽象。生产环境由 JPA 实现,测试/演示由内存实现。
*/
public interface UserStore {
Optional<UserAccount> findByUserId(String userId);
Optional<UserAccount> findByUsername(String username);
boolean existsByUsername(String username);
UserAccount save(UserAccount account);
List<UserAccount> listAll();
}