feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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\":\"认证失败\"}");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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>默认文本消息缓冲区为 8KB,WebRTC SDP(OFFER/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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 与 deviceSecret(deviceSecret 仅此一次明文返回)。
|
||||
*/
|
||||
@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()));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
/**
|
||||
* 账号 / 设备状态。
|
||||
*/
|
||||
public enum AccountStatus {
|
||||
/** 正常 */
|
||||
ACTIVE,
|
||||
/** 临时封禁(到期自动恢复) */
|
||||
SUSPENDED,
|
||||
/** 永久封禁 */
|
||||
BANNED
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
/**
|
||||
* 已鉴权主体:由服务端在握手/请求鉴权阶段裁定,客户端无法自行声明。
|
||||
*
|
||||
* @param principalType 主体类型(用户 / 设备)
|
||||
* @param principalId 主体 ID(userId 或 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
/**
|
||||
* 连接主体类型:区分“用户账号”(主控端)与“设备身份”(被控端)。
|
||||
*/
|
||||
public enum PrincipalType {
|
||||
/** 主控端:由用户账号登录后取得身份 */
|
||||
USER,
|
||||
/** 被控端:由设备激活后取得身份(系统签名应用,可获取固定 SN) */
|
||||
DEVICE
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
// 无需处理
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 轻量 JWT(HS256)签发与校验服务。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* TOTP(RFC 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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} 抽象,生产环境使用 MySQL(JPA),
|
||||
* 测试/演示使用内存实现,调用方无感知。
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 实现,配对码持久化到 MySQL(storage.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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user