feat(android): add device activation flow and update dependencies
重构项目架构,引入启动页(SplashActivity)检查激活状态,未激活设备引导至激活页(ActivationActivity)完成provision+token流程。集成Retrofit+RxJava3网络层、Room数据库、Lifecycle组件、MMKV等依赖,升级OkHttp/Gson版本,并将构建配置从旧项目全面迁移至新项目结构。
This commit is contained in:
@@ -1,70 +1,91 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
import com.ttstd.signaling.model.AuthPrincipal;
|
||||
import com.ttstd.signaling.security.TokenUtils;
|
||||
import com.ttstd.signaling.security.AuthException;
|
||||
import com.ttstd.signaling.service.AdminService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台管理接口鉴权过滤器:仅保护 /api/admin 下的接口。
|
||||
* 后台管理接口鉴权过滤器:校验 {@code Authorization: Bearer admt_...} 管理员令牌。
|
||||
*
|
||||
* <p>支持两种凭据,满足其一即可通过:
|
||||
* <ol>
|
||||
* <li>请求头 {@code X-Admin-Token} 与配置令牌一致(兼容既有管理后台登录方式)</li>
|
||||
* <li>{@code Authorization: Bearer <token>} 且账号具备管理员角色
|
||||
* (由 {@link BearerAuthFilter} 预先解析)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>过滤器顺序在 {@link BearerAuthFilter} 之后,以便读取其解析出的主体。
|
||||
* <p>与主控端用户体系完全独立,使用 {@link AdminService} 专用令牌(前缀 admt_)。
|
||||
* 仅对 {@code /api/admin/**} 生效;免鉴权路径(如登录、健康检查)统一由
|
||||
* {@link PublicEndpoints} 管理。
|
||||
*/
|
||||
@Component
|
||||
@Order(2)
|
||||
public class AdminAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
@Value("${admin.token:webrtc-admin-token}")
|
||||
private String adminToken;
|
||||
private final AdminService adminService;
|
||||
|
||||
public AdminAuthFilter(AdminService adminService) {
|
||||
this.adminService = adminService;
|
||||
}
|
||||
|
||||
private static final String PREFIX = "/api/admin/";
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getServletPath();
|
||||
if (path == null || !path.startsWith(PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
// 免鉴权路径(由 PublicEndpoints 统一管理,例如登录接口)放行
|
||||
return PublicEndpoints.isPublic(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
if (!uri.startsWith("/api/admin")) {
|
||||
filterChain.doFilter(request, response);
|
||||
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
writeUnauthorized(response, "缺少管理员令牌");
|
||||
return;
|
||||
}
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasValidAdminToken(request) || hasAdminPrincipal(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
String token = header.substring("Bearer ".length()).trim();
|
||||
if (token.isEmpty()) {
|
||||
writeUnauthorized(response, "管理员令牌为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> claims = adminService.verifyToken(token);
|
||||
String sub = com.ttstd.signaling.security.JwtService.claimAsString(claims, "sub");
|
||||
AdminAuthPrincipal principal = new AdminAuthPrincipal(sub);
|
||||
request.setAttribute("adminPrincipal", principal);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (AuthException e) {
|
||||
writeUnauthorized(response, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
writeUnauthorized(response, "令牌校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"未授权:无效的管理员凭据\"}");
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
String body = "{\"error\":\"unauthorized\",\"message\":\""
|
||||
+ escape(message) + "\"}";
|
||||
response.getWriter().write(body);
|
||||
}
|
||||
|
||||
private boolean hasValidAdminToken(HttpServletRequest request) {
|
||||
String token = request.getHeader("X-Admin-Token");
|
||||
return adminToken != null && !adminToken.isBlank()
|
||||
&& TokenUtils.constantTimeEquals(adminToken, token);
|
||||
private static String escape(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private boolean hasAdminPrincipal(HttpServletRequest request) {
|
||||
Object principal = request.getAttribute(BearerAuthFilter.ATTR_PRINCIPAL);
|
||||
return principal instanceof AuthPrincipal auth && auth.admin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
/** 后台管理员鉴权主体,仅携带管理员 ID。 */
|
||||
public class AdminAuthPrincipal {
|
||||
|
||||
private final String adminId;
|
||||
|
||||
public AdminAuthPrincipal(String adminId) {
|
||||
this.adminId = adminId;
|
||||
}
|
||||
|
||||
public String getAdminId() {
|
||||
return adminId;
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,6 @@ public class BearerAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String ATTR_PRINCIPAL = "authPrincipal";
|
||||
|
||||
/** 无需认证即可访问的端点 */
|
||||
private static final Set<String> PUBLIC_PATHS = Set.of(
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/auth/refresh",
|
||||
"/api/device/provision",
|
||||
"/api/device/token");
|
||||
|
||||
/** 需要令牌的受保护端点前缀(用户或设备令牌均可) */
|
||||
private static final Set<String> PROTECTED_PREFIXES = Set.of("/api/auth/", "/api/client/");
|
||||
|
||||
@@ -54,7 +46,7 @@ public class BearerAuthFilter extends OncePerRequestFilter {
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod()) || PUBLIC_PATHS.contains(uri)) {
|
||||
if (PublicEndpoints.isPublic(request)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 统一管理“无需鉴权即可访问”的端点。
|
||||
*
|
||||
* <p>所有免登录/免令牌的公开接口集中在此登记,避免各过滤器各自硬编码放行规则
|
||||
* 导致遗漏或冲突(例如登录接口被误拦截返回 401)。
|
||||
*
|
||||
* <p>匹配规则:
|
||||
* <ul>
|
||||
* <li>{@link #EXACT} 中的精确路径(含任意 HTTP 方法)一律放行;</li>
|
||||
* <li>{@link #PREFIXES} 中的前缀路径一律放行;</li>
|
||||
* <li>OPTIONS 预检请求一律放行;</li>
|
||||
* <li>其余路径交由对应鉴权过滤器处理。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PublicEndpoints {
|
||||
|
||||
private PublicEndpoints() {
|
||||
}
|
||||
|
||||
/** 精确免鉴权路径(不区分 HTTP 方法)。 */
|
||||
public static final Set<String> EXACT = Set.of(
|
||||
// 主控端用户体系
|
||||
"/api/auth/register",
|
||||
"/api/auth/login",
|
||||
"/api/auth/refresh",
|
||||
// 设备激活 / 设备令牌
|
||||
"/api/device/provision",
|
||||
"/api/device/token",
|
||||
// 后台管理员:登录接口免鉴权(其余后台接口均需 admt_ 令牌)
|
||||
"/api/admin/login",
|
||||
// 后台健康检查(运维探针)
|
||||
"/api/admin/health"
|
||||
);
|
||||
|
||||
/** 免鉴权前缀。 */
|
||||
public static final Set<String> PREFIXES = Set.of(
|
||||
// WebSocket 握手前的公开信令协商路径(如有)
|
||||
);
|
||||
|
||||
/** 判断请求是否免鉴权。 */
|
||||
public static boolean isPublic(HttpServletRequest request) {
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
String path = request.getServletPath();
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
if (EXACT.contains(path)) {
|
||||
return true;
|
||||
}
|
||||
for (String prefix : PREFIXES) {
|
||||
if (path.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ttstd.signaling.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
|
||||
/**
|
||||
* 启动期数据库 schema 自愈:清理历史遗留的废弃表。
|
||||
*
|
||||
* <p>旧版本曾存在独立的 {@code password_history} 表(含外键
|
||||
* {@code fk_pwdhist_user} 指向 {@code app_user.user_id})。当前代码已将其合并为
|
||||
* {@code app_user.password_history} 字段,不再使用该表,但其外键会阻止 Hibernate
|
||||
* 在 {@code ddl-auto: update} 下对 {@code app_user.user_id} 列做必要迁移,导致启动失败。
|
||||
*
|
||||
* <p>本组件在 JPA 执行 DDL 之前(仅依赖 DataSource,不依赖任何 JPA 组件)幂等删除该废弃表。
|
||||
* 可通过 {@code security.schema.cleanup-enabled=false} 关闭。
|
||||
*/
|
||||
@Component
|
||||
@Order(0)
|
||||
@ConditionalOnProperty(name = "security.schema.cleanup-enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class SchemaCleanup {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SchemaCleanup.class);
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public SchemaCleanup(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void cleanup() {
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
// 仅清理确已废弃、且代码无任何实体/Repository 引用的遗留表
|
||||
stmt.execute("DROP TABLE IF EXISTS password_history");
|
||||
logger.info("已清理废弃表 password_history(若存在)");
|
||||
} catch (Exception e) {
|
||||
// 清理失败不应阻断启动;记录告警,留给人工处理
|
||||
logger.warn("清理废弃表 password_history 失败(可忽略,手动 DROP 亦可): {}", e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package com.ttstd.signaling.config;
|
||||
|
||||
import com.ttstd.signaling.security.SecurityProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
/**
|
||||
* 启用安全相关配置属性绑定与定时任务。
|
||||
@@ -12,4 +15,13 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
@EnableScheduling
|
||||
public class SecurityBeansConfig {
|
||||
|
||||
/**
|
||||
* 全局密码编码器 Bean。供 AdminService(后台管理员)注入使用,
|
||||
* 与 AccountService 内部使用的 BCrypt(strength=12) 保持一致。
|
||||
*/
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,19 @@ import com.ttstd.signaling.manager.ConnectionRequestManager;
|
||||
import com.ttstd.signaling.manager.SessionManager;
|
||||
import com.ttstd.signaling.manager.SignalMetrics;
|
||||
import com.ttstd.signaling.model.AbuseReport;
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
import com.ttstd.signaling.model.DeviceAccount;
|
||||
import com.ttstd.signaling.model.DeviceBinding;
|
||||
import com.ttstd.signaling.model.DeviceInfo;
|
||||
import com.ttstd.signaling.model.UserAccount;
|
||||
import com.ttstd.signaling.service.AbuseReportService;
|
||||
import com.ttstd.signaling.service.AccountService;
|
||||
import com.ttstd.signaling.service.AdminService;
|
||||
import com.ttstd.signaling.service.AuditService;
|
||||
import com.ttstd.signaling.service.BindingService;
|
||||
import com.ttstd.signaling.service.DeviceIdentityService;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -30,7 +34,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 后台管理 REST 接口:为 WebRTCSignalServerWeb 提供服务器运行态查询。
|
||||
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 统一鉴权。
|
||||
* 所有接口均以 {@code /api/admin} 为前缀,并由 {@link AdminAuthFilter} 校验管理员令牌。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@@ -44,6 +48,7 @@ public class AdminController {
|
||||
private final AuditService auditService;
|
||||
private final BindingService bindingService;
|
||||
private final AbuseReportService abuseReportService;
|
||||
private final AdminService adminService;
|
||||
private final long startTime = System.currentTimeMillis();
|
||||
|
||||
public AdminController(SessionManager sessionManager,
|
||||
@@ -53,7 +58,8 @@ public class AdminController {
|
||||
DeviceIdentityService deviceIdentityService,
|
||||
AuditService auditService,
|
||||
BindingService bindingService,
|
||||
AbuseReportService abuseReportService) {
|
||||
AbuseReportService abuseReportService,
|
||||
AdminService adminService) {
|
||||
this.sessionManager = sessionManager;
|
||||
this.connectionRequestManager = connectionRequestManager;
|
||||
this.metrics = metrics;
|
||||
@@ -62,6 +68,7 @@ public class AdminController {
|
||||
this.auditService = auditService;
|
||||
this.bindingService = bindingService;
|
||||
this.abuseReportService = abuseReportService;
|
||||
this.adminService = adminService;
|
||||
}
|
||||
|
||||
/** 仪表盘汇总数据:设备在线情况、待确认连接、流量指标与运行时长。 */
|
||||
@@ -129,12 +136,158 @@ public class AdminController {
|
||||
return data;
|
||||
}
|
||||
|
||||
// ==================== 管理员登录 ====================
|
||||
|
||||
/**
|
||||
* 后台管理员登录,签发独立的管理员令牌(前缀 admt_)。
|
||||
* 该接口免鉴权;请求体:{@code {"username": "admin", "password": "***"}}。
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public Map<String, Object> adminLogin(@RequestBody Map<String, Object> body) {
|
||||
String username = (String) body.get("username");
|
||||
String password = (String) body.get("password");
|
||||
return adminService.login(username, password);
|
||||
}
|
||||
|
||||
// ==================== 管理员管理 ====================
|
||||
|
||||
/** 当前登录的管理员自身信息。 */
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> adminMe(jakarta.servlet.http.HttpServletRequest request) {
|
||||
String adminId = currentAdminId(request);
|
||||
return Map.of("adminId", adminId);
|
||||
}
|
||||
|
||||
/** 管理员列表。 */
|
||||
@GetMapping("/admins")
|
||||
public List<Map<String, Object>> listAdmins() {
|
||||
return adminService.listAdmins().stream().map(AdminController::toAdminView).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增后台管理员。
|
||||
* 请求体:{@code {"username": "op1", "password": "***", "displayName": "运维1"}}。
|
||||
*/
|
||||
@PostMapping("/admins")
|
||||
public Map<String, Object> createAdmin(@RequestBody Map<String, Object> body) {
|
||||
String username = (String) body.get("username");
|
||||
String password = (String) body.get("password");
|
||||
String displayName = (String) body.get("displayName");
|
||||
AdminAccount created = adminService.createAdmin(username, password, displayName);
|
||||
return Map.of("success", true, "admin", toAdminView(created));
|
||||
}
|
||||
|
||||
/** 禁用管理员(保留记录,无法登录)。 */
|
||||
@PostMapping("/admins/{adminId}/disable")
|
||||
public Map<String, Object> disableAdmin(@PathVariable String adminId) {
|
||||
adminService.disableAdmin(adminId);
|
||||
return Map.of("success", true, "adminId", adminId);
|
||||
}
|
||||
|
||||
/** 启用管理员。 */
|
||||
@PostMapping("/admins/{adminId}/enable")
|
||||
public Map<String, Object> enableAdmin(@PathVariable String adminId) {
|
||||
adminService.enableAdmin(adminId);
|
||||
return Map.of("success", true, "adminId", adminId);
|
||||
}
|
||||
|
||||
/** 删除管理员(不可删除最后一个启用状态的管理员)。 */
|
||||
@DeleteMapping("/admins/{adminId}")
|
||||
public Map<String, Object> deleteAdmin(@PathVariable String adminId) {
|
||||
adminService.deleteAdmin(adminId);
|
||||
return Map.of("success", true, "adminId", adminId);
|
||||
}
|
||||
|
||||
/** 重置管理员密码。请求体:{@code {"password": "***"}}。 */
|
||||
@PostMapping("/admins/{adminId}/reset-password")
|
||||
public Map<String, Object> resetAdminPassword(@PathVariable String adminId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String password = (String) body.get("password");
|
||||
if (password == null || password.isBlank()) {
|
||||
return Map.of("success", false, "message", "新密码不能为空");
|
||||
}
|
||||
adminService.resetPassword(adminId, password);
|
||||
return Map.of("success", true, "adminId", adminId);
|
||||
}
|
||||
|
||||
private String currentAdminId(jakarta.servlet.http.HttpServletRequest request) {
|
||||
Object principal = request.getAttribute("adminPrincipal");
|
||||
if (principal instanceof com.ttstd.signaling.config.AdminAuthPrincipal p) {
|
||||
return p.getAdminId();
|
||||
}
|
||||
throw com.ttstd.signaling.security.AuthException.unauthorized("未识别管理员身份");
|
||||
}
|
||||
|
||||
private static Map<String, Object> toAdminView(AdminAccount a) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("adminId", a.getAdminId());
|
||||
item.put("username", a.getUsername());
|
||||
item.put("displayName", a.getDisplayName());
|
||||
item.put("status", a.getStatus().name());
|
||||
item.put("createdAt", a.getCreatedAt().toEpochMilli());
|
||||
item.put("lastLoginAt", a.getLastLoginAt() == null ? null : a.getLastLoginAt().toEpochMilli());
|
||||
return item;
|
||||
}
|
||||
|
||||
// ==================== 账号管理 ====================
|
||||
|
||||
/** 主控端账号列表。 */
|
||||
/**
|
||||
* 主控端账号列表,支持按用户名关键字与状态过滤。
|
||||
*
|
||||
* @param keyword 用户名模糊匹配,忽略大小写
|
||||
* @param status 账号状态:ACTIVE / SUSPENDED / BANNED
|
||||
*/
|
||||
@GetMapping("/users")
|
||||
public List<Map<String, Object>> users() {
|
||||
return accountService.listUsers().stream().map(AdminController::toUserView).toList();
|
||||
public List<Map<String, Object>> users(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status) {
|
||||
return accountService.listUsers().stream()
|
||||
.filter(u -> keyword == null || keyword.isBlank()
|
||||
|| u.getUsername().toLowerCase().contains(keyword.toLowerCase()))
|
||||
.filter(u -> status == null || status.isBlank()
|
||||
|| u.getStatus().name().equalsIgnoreCase(status))
|
||||
.map(AdminController::toUserView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增账号。
|
||||
*
|
||||
* <p>请求体:{@code {"username": "alice", "password": "***"}}
|
||||
*
|
||||
* <p>此处创建的是普通用户(app_user),与管理员(admin_account)完全隔离。
|
||||
* 若需创建后台管理员,请调用 {@code POST /api/admin/admins}。
|
||||
*/
|
||||
@PostMapping("/users")
|
||||
public Map<String, Object> createUser(@RequestBody Map<String, Object> body) {
|
||||
String username = (String) body.get("username");
|
||||
String password = (String) body.get("password");
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
return Map.of("success", false, "message", "用户名与密码不能为空");
|
||||
}
|
||||
UserAccount created = accountService.adminCreateUser(username.trim(), password);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("user", toUserView(created));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 删除账号,同时下线其全部会话。 */
|
||||
@DeleteMapping("/users/{userId}")
|
||||
public Map<String, Object> deleteUser(@PathVariable String userId) {
|
||||
accountService.deleteUser(userId);
|
||||
return Map.of("success", true, "userId", userId);
|
||||
}
|
||||
|
||||
/** 管理员重置账号密码。请求体:{@code {"password": "***"}} */
|
||||
@PostMapping("/users/{userId}/reset-password")
|
||||
public Map<String, Object> resetPassword(@PathVariable String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String password = (String) body.get("password");
|
||||
if (password == null || password.isBlank()) {
|
||||
return Map.of("success", false, "message", "新密码不能为空");
|
||||
}
|
||||
accountService.adminResetPassword(userId, password);
|
||||
return Map.of("success", true, "userId", userId);
|
||||
}
|
||||
|
||||
/** 指定账号的活跃登录会话。 */
|
||||
@@ -195,21 +348,57 @@ public class AdminController {
|
||||
|
||||
// ==================== 设备管理 ====================
|
||||
|
||||
/** 已激活的被控端设备列表(SN 已脱敏)。 */
|
||||
/**
|
||||
* 已激活的被控端设备列表(SN 已脱敏)。
|
||||
*
|
||||
* @param keyword 按设备 UID / 型号模糊匹配
|
||||
* @param status 设备状态:ACTIVE / SUSPENDED / BANNED
|
||||
* @param online 在线状态过滤,null 表示不过滤
|
||||
*/
|
||||
@GetMapping("/device-accounts")
|
||||
public List<Map<String, Object>> deviceAccounts() {
|
||||
return deviceIdentityService.listDevices().stream().map(d -> {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("deviceUid", d.getDeviceUid());
|
||||
item.put("sn", d.maskedSn());
|
||||
item.put("model", d.getModel());
|
||||
item.put("status", d.getStatus().name());
|
||||
item.put("statusReason", d.getStatusReason());
|
||||
item.put("provisionedAt", d.getProvisionedAt().toEpochMilli());
|
||||
item.put("lastOnlineAt", d.getLastOnlineAt() == null ? null : d.getLastOnlineAt().toEpochMilli());
|
||||
item.put("online", sessionManager.isDeviceOnline(d.getDeviceUid()));
|
||||
return item;
|
||||
}).toList();
|
||||
public List<Map<String, Object>> deviceAccounts(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) Boolean online) {
|
||||
return deviceIdentityService.listDevices().stream()
|
||||
.filter(d -> keyword == null || keyword.isBlank()
|
||||
|| d.getDeviceUid().toLowerCase().contains(keyword.toLowerCase())
|
||||
|| (d.getModel() != null && d.getModel().toLowerCase().contains(keyword.toLowerCase())))
|
||||
.filter(d -> status == null || status.isBlank()
|
||||
|| d.getStatus().name().equalsIgnoreCase(status))
|
||||
.filter(d -> online == null || online == sessionManager.isDeviceOnline(d.getDeviceUid()))
|
||||
.map(this::toDeviceView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 强制设备下线:吊销现有令牌并断开连接,但不改变设备状态。 */
|
||||
@PostMapping("/device-accounts/{deviceUid}/kick")
|
||||
public Map<String, Object> kickDevice(@PathVariable String deviceUid,
|
||||
@RequestBody(required = false) Map<String, Object> body) {
|
||||
String reason = body == null ? "管理员强制下线"
|
||||
: (String) body.getOrDefault("reason", "管理员强制下线");
|
||||
deviceIdentityService.kick(deviceUid, reason);
|
||||
return Map.of("success", true, "deviceUid", deviceUid);
|
||||
}
|
||||
|
||||
/** 删除设备记录并断开其连接。 */
|
||||
@DeleteMapping("/device-accounts/{deviceUid}")
|
||||
public Map<String, Object> deleteDevice(@PathVariable String deviceUid) {
|
||||
deviceIdentityService.delete(deviceUid);
|
||||
return Map.of("success", true, "deviceUid", deviceUid);
|
||||
}
|
||||
|
||||
private Map<String, Object> toDeviceView(DeviceAccount d) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("deviceUid", d.getDeviceUid());
|
||||
item.put("sn", d.maskedSn());
|
||||
item.put("model", d.getModel());
|
||||
item.put("status", d.getStatus().name());
|
||||
item.put("statusReason", d.getStatusReason());
|
||||
item.put("statusUntil", d.getStatusUntil() == null ? null : d.getStatusUntil().toEpochMilli());
|
||||
item.put("provisionedAt", d.getProvisionedAt().toEpochMilli());
|
||||
item.put("lastOnlineAt", d.getLastOnlineAt() == null ? null : d.getLastOnlineAt().toEpochMilli());
|
||||
item.put("online", sessionManager.isDeviceOnline(d.getDeviceUid()));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 禁用设备并立即断开其连接。 */
|
||||
@@ -243,6 +432,21 @@ public class AdminController {
|
||||
"total", deviceIdentityService.getAllowlist().size());
|
||||
}
|
||||
|
||||
/** 查询 SN 白名单。 */
|
||||
@GetMapping("/device-allowlist")
|
||||
public Map<String, Object> listAllowlist() {
|
||||
List<String> sns = deviceIdentityService.getAllowlist().stream().sorted().toList();
|
||||
return Map.of("total", sns.size(), "sns", sns);
|
||||
}
|
||||
|
||||
/** 将指定 SN 移出白名单。 */
|
||||
@DeleteMapping("/device-allowlist/{sn}")
|
||||
public Map<String, Object> removeAllowlist(@PathVariable String sn) {
|
||||
boolean removed = deviceIdentityService.removeFromAllowlist(sn);
|
||||
return Map.of("success", removed, "sn", sn,
|
||||
"total", deviceIdentityService.getAllowlist().size());
|
||||
}
|
||||
|
||||
// ==================== 绑定 / 黑名单管理 ====================
|
||||
|
||||
/** 列出某设备的全部绑定关系(含已撤销)。 */
|
||||
@@ -400,21 +604,21 @@ public class AdminController {
|
||||
* 默认返回最近 100 条,最多 1000 条。
|
||||
*/
|
||||
@GetMapping("/audit")
|
||||
public List<AuditService.AuditEntry> audit(
|
||||
public Map<String, Object> audit(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@RequestParam(required = false) String actorId,
|
||||
@RequestParam(required = false) String action,
|
||||
@RequestParam(required = false) String result) {
|
||||
int cap = Math.min(Math.max(limit, 1), 1000);
|
||||
List<AuditService.AuditEntry> all = auditService.recent(cap);
|
||||
if (actorId == null && action == null && result == null) {
|
||||
return all;
|
||||
}
|
||||
return all.stream()
|
||||
.filter(e -> actorId == null || actorId.equals(e.actorId()))
|
||||
.filter(e -> action == null || action.equals(e.action()))
|
||||
.filter(e -> result == null || result.equals(e.result()))
|
||||
.toList();
|
||||
Page<AuditService.AuditEntry> found =
|
||||
auditService.search(actorId, action, result, page, limit);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("items", found.getContent());
|
||||
body.put("page", found.getNumber());
|
||||
body.put("size", found.getSize());
|
||||
body.put("total", found.getTotalElements());
|
||||
body.put("totalPages", found.getTotalPages());
|
||||
return body;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toUserView(UserAccount u) {
|
||||
@@ -424,7 +628,6 @@ public class AdminController {
|
||||
item.put("status", u.getStatus().name());
|
||||
item.put("statusReason", u.getStatusReason());
|
||||
item.put("statusUntil", u.getStatusUntil() == null ? null : u.getStatusUntil().toEpochMilli());
|
||||
item.put("admin", u.isAdmin());
|
||||
item.put("locked", u.isLocked());
|
||||
item.put("createdAt", u.getCreatedAt().toEpochMilli());
|
||||
item.put("lastLoginAt", u.getLastLoginAt() == null ? null : u.getLastLoginAt().toEpochMilli());
|
||||
|
||||
@@ -150,7 +150,6 @@ public class AuthController {
|
||||
body.put("displayName", principal.displayName());
|
||||
body.put("sessionId", principal.sessionId());
|
||||
body.put("signalDeviceId", principal.deviceId());
|
||||
body.put("admin", principal.admin());
|
||||
// 便于客户端展示"是否已开启双因子",仅对用户身份有效
|
||||
if (principal.isUser()) {
|
||||
body.put("totpEnabled",
|
||||
|
||||
@@ -6,6 +6,8 @@ package com.ttstd.signaling.model;
|
||||
public enum AccountStatus {
|
||||
/** 正常 */
|
||||
ACTIVE,
|
||||
/** 管理员被禁用(无法登录后台) */
|
||||
DISABLED,
|
||||
/** 临时封禁(到期自动恢复) */
|
||||
SUSPENDED,
|
||||
/** 永久封禁 */
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 后台管理员账号。
|
||||
*
|
||||
* 与主控端 {@link UserAccount} 完全独立,使用单独的数据库表(admin_account)与
|
||||
* 独立的 token 机制(admt_ 前缀的 JWT),不参与普通用户的注册/登录/绑定流程。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "admin_account")
|
||||
public class AdminAccount {
|
||||
|
||||
@Id
|
||||
@Column(name = "admin_id", length = 64, nullable = false)
|
||||
private String adminId;
|
||||
|
||||
@Column(name = "username", length = 64, unique = true, nullable = false)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password_hash", length = 100, nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "display_name", length = 64)
|
||||
private String displayName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", length = 16, nullable = false)
|
||||
private AccountStatus status = AccountStatus.ACTIVE;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "last_login_at")
|
||||
private Instant lastLoginAt;
|
||||
|
||||
public AdminAccount() {
|
||||
}
|
||||
|
||||
public AdminAccount(String adminId, String username, String passwordHash) {
|
||||
this.adminId = adminId;
|
||||
this.username = username;
|
||||
this.passwordHash = passwordHash;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public String getAdminId() {
|
||||
return adminId;
|
||||
}
|
||||
|
||||
public void setAdminId(String adminId) {
|
||||
this.adminId = adminId;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public AccountStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(AccountStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public boolean isDisabled() {
|
||||
return status == AccountStatus.DISABLED;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public Instant getLastLoginAt() {
|
||||
return lastLoginAt;
|
||||
}
|
||||
|
||||
public void setLastLoginAt(Instant lastLoginAt) {
|
||||
this.lastLoginAt = lastLoginAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.ttstd.signaling.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 安全审计日志(持久化到数据库)。
|
||||
*
|
||||
* <p>记录登录、激活、封禁、踢线等安全敏感操作,用于事后追溯。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "audit_log", indexes = {
|
||||
@Index(name = "idx_audit_actor", columnList = "actor_id"),
|
||||
@Index(name = "idx_audit_action", columnList = "action"),
|
||||
@Index(name = "idx_audit_created", columnList = "created_at")
|
||||
})
|
||||
public class AuditLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
/** 主体类型:USER / DEVICE / ADMIN */
|
||||
@Column(name = "actor_type", length = 16)
|
||||
private String actorType;
|
||||
|
||||
@Column(name = "actor_id", length = 64)
|
||||
private String actorId;
|
||||
|
||||
@Column(name = "action", length = 48, nullable = false)
|
||||
private String action;
|
||||
|
||||
@Column(name = "target_id", length = 64)
|
||||
private String targetId;
|
||||
|
||||
/** SUCCESS / FAILURE */
|
||||
@Column(name = "result", length = 16)
|
||||
private String result;
|
||||
|
||||
@Column(name = "ip", length = 64)
|
||||
private String ip;
|
||||
|
||||
@Column(name = "detail", length = 512)
|
||||
private String detail;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected AuditLog() {
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public AuditLog(String actorType, String actorId, String action,
|
||||
String targetId, String result, String ip, String detail) {
|
||||
this.actorType = actorType;
|
||||
this.actorId = actorId;
|
||||
this.action = action;
|
||||
this.targetId = targetId;
|
||||
this.result = result;
|
||||
this.ip = ip;
|
||||
this.detail = detail;
|
||||
this.createdAt = Instant.now();
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public String getActorType() { return actorType; }
|
||||
public String getActorId() { return actorId; }
|
||||
public String getAction() { return action; }
|
||||
public String getTargetId() { return targetId; }
|
||||
public String getResult() { return result; }
|
||||
public String getIp() { return ip; }
|
||||
public String getDetail() { return detail; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
}
|
||||
@@ -9,7 +9,6 @@ package com.ttstd.signaling.model;
|
||||
* @param deviceType 信令角色(CONTROLLER / CONTROLLED)
|
||||
* @param sessionId 登录会话 ID,用于精准踢线
|
||||
* @param displayName 展示名(用户名 / 设备型号),仅用于日志与管理后台
|
||||
* @param admin 是否为管理员账号
|
||||
*/
|
||||
public record AuthPrincipal(
|
||||
PrincipalType principalType,
|
||||
@@ -17,8 +16,7 @@ public record AuthPrincipal(
|
||||
String deviceId,
|
||||
DeviceType deviceType,
|
||||
String sessionId,
|
||||
String displayName,
|
||||
boolean admin) {
|
||||
String displayName) {
|
||||
|
||||
public boolean isUser() {
|
||||
return principalType == PrincipalType.USER;
|
||||
|
||||
@@ -53,9 +53,6 @@ public class UserAccount {
|
||||
@Column(name = "token_version", nullable = false)
|
||||
private long tokenVersion = 1L;
|
||||
|
||||
@Column(name = "is_admin", nullable = false)
|
||||
private boolean admin;
|
||||
|
||||
/** 连续登录失败次数与锁定截止时间,用于防爆破 */
|
||||
@Column(name = "failed_attempts", nullable = false)
|
||||
private int failedAttempts;
|
||||
@@ -121,9 +118,6 @@ public class UserAccount {
|
||||
public long getTokenVersion() { return tokenVersion; }
|
||||
public void setTokenVersion(long tokenVersion) { this.tokenVersion = tokenVersion; }
|
||||
|
||||
public boolean isAdmin() { return admin; }
|
||||
public void setAdmin(boolean admin) { this.admin = admin; }
|
||||
|
||||
public int getFailedAttempts() { return failedAttempts; }
|
||||
public void setFailedAttempts(int failedAttempts) { this.failedAttempts = failedAttempts; }
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ttstd.signaling.repository;
|
||||
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface AdminAccountRepository extends JpaRepository<AdminAccount, String> {
|
||||
|
||||
Optional<AdminAccount> findByUsername(String username);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
long countByStatusNot(com.ttstd.signaling.model.AccountStatus status);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ttstd.signaling.repository;
|
||||
|
||||
import com.ttstd.signaling.model.AuditLog;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 审计日志持久化仓库。
|
||||
*/
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
|
||||
|
||||
/**
|
||||
* 按可选条件分页查询,任一参数为 null 时视为不过滤。
|
||||
*/
|
||||
@Query("""
|
||||
SELECT a FROM AuditLog a
|
||||
WHERE (:actorId IS NULL OR a.actorId = :actorId)
|
||||
AND (:action IS NULL OR a.action = :action)
|
||||
AND (:result IS NULL OR a.result = :result)
|
||||
ORDER BY a.id DESC
|
||||
""")
|
||||
Page<AuditLog> search(@Param("actorId") String actorId,
|
||||
@Param("action") String action,
|
||||
@Param("result") String result,
|
||||
Pageable pageable);
|
||||
|
||||
/** 清理指定时间点之前的历史日志,返回删除条数。 */
|
||||
@Modifying
|
||||
@Query("DELETE FROM AuditLog a WHERE a.createdAt < :before")
|
||||
int deleteOlderThan(@Param("before") Instant before);
|
||||
}
|
||||
@@ -11,12 +11,14 @@ public class SecurityProperties {
|
||||
private final Jwt jwt = new Jwt();
|
||||
private final Device device = new Device();
|
||||
private final Account account = new Account();
|
||||
private final Admin admin = new Admin();
|
||||
private final WebSocket websocket = new WebSocket();
|
||||
private final Turn turn = new Turn();
|
||||
|
||||
public Jwt getJwt() { return jwt; }
|
||||
public Device getDevice() { return device; }
|
||||
public Account getAccount() { return account; }
|
||||
public Admin getAdmin() { return admin; }
|
||||
public WebSocket getWebsocket() { return websocket; }
|
||||
public Turn getTurn() { return turn; }
|
||||
|
||||
@@ -67,8 +69,6 @@ public class SecurityProperties {
|
||||
private int maxFailedAttempts = 5;
|
||||
private long lockDurationSeconds = 900;
|
||||
private int maxConcurrentSessions = 5;
|
||||
private String bootstrapUsername = "admin";
|
||||
private String bootstrapPassword;
|
||||
|
||||
public boolean isRegistrationEnabled() { return registrationEnabled; }
|
||||
public void setRegistrationEnabled(boolean v) { this.registrationEnabled = v; }
|
||||
@@ -81,12 +81,22 @@ public class SecurityProperties {
|
||||
|
||||
public int getMaxConcurrentSessions() { return maxConcurrentSessions; }
|
||||
public void setMaxConcurrentSessions(int v) { this.maxConcurrentSessions = v; }
|
||||
}
|
||||
|
||||
/** 后台管理员专用配置(独立于普通用户体系)。 */
|
||||
public static class Admin {
|
||||
private String bootstrapUsername = "admin";
|
||||
private String bootstrapPassword;
|
||||
private long tokenTtlSeconds = 3600;
|
||||
|
||||
public String getBootstrapUsername() { return bootstrapUsername; }
|
||||
public void setBootstrapUsername(String v) { this.bootstrapUsername = v; }
|
||||
|
||||
public String getBootstrapPassword() { return bootstrapPassword; }
|
||||
public void setBootstrapPassword(String v) { this.bootstrapPassword = v; }
|
||||
|
||||
public long getTokenTtlSeconds() { return tokenTtlSeconds; }
|
||||
public void setTokenTtlSeconds(long v) { this.tokenTtlSeconds = v; }
|
||||
}
|
||||
|
||||
public static class WebSocket {
|
||||
|
||||
@@ -82,27 +82,6 @@ public class AccountService {
|
||||
this.revocationListener = listener;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void bootstrap() {
|
||||
String username = properties.getAccount().getBootstrapUsername();
|
||||
String password = properties.getAccount().getBootstrapPassword();
|
||||
if (username == null || username.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (password == null || password.isBlank()) {
|
||||
logger.warn("未配置 BOOTSTRAP_ADMIN_PASSWORD,跳过初始管理员账号创建。"
|
||||
+ "可通过环境变量配置后重启,或使用注册接口创建账号。");
|
||||
return;
|
||||
}
|
||||
if (userStore.existsByUsername(username)) {
|
||||
return;
|
||||
}
|
||||
UserAccount admin = createAccountInternal(username, password);
|
||||
admin.setAdmin(true);
|
||||
userStore.save(admin);
|
||||
logger.info("已创建初始管理员账号: {}", username);
|
||||
}
|
||||
|
||||
// ==================== 注册 ====================
|
||||
|
||||
public UserAccount register(String username, String password) {
|
||||
@@ -327,7 +306,6 @@ public class AccountService {
|
||||
claims.put("typ", PrincipalType.USER.name());
|
||||
claims.put("sid", sessionId);
|
||||
claims.put("name", account.getUsername());
|
||||
claims.put("adm", account.isAdmin());
|
||||
return jwtService.issue(account.getUserId(), JwtService.PURPOSE_ACCESS,
|
||||
properties.getJwt().getAccessTokenTtlSeconds(),
|
||||
account.getTokenVersion(), claims);
|
||||
@@ -406,7 +384,7 @@ public class AccountService {
|
||||
|
||||
String signalDeviceId = "ctl_" + sessionId;
|
||||
return new AuthPrincipal(PrincipalType.USER, userId, signalDeviceId,
|
||||
DeviceType.CONTROLLER, sessionId, account.getUsername(), account.isAdmin());
|
||||
DeviceType.CONTROLLER, sessionId, account.getUsername());
|
||||
}
|
||||
|
||||
// ==================== 登出 / 封禁 / 踢线 ====================
|
||||
@@ -507,6 +485,54 @@ public class AccountService {
|
||||
AuditService.RESULT_SUCCESS, null, "密码修改成功,已吊销全部会话");
|
||||
}
|
||||
|
||||
// ==================== 管理端账号维护 ====================
|
||||
|
||||
/**
|
||||
* 管理员直接创建账号,绕过“是否开放注册”开关。
|
||||
*
|
||||
* <p>注意:此处的账号是普通用户(写入 app_user),与管理员(写入 admin_account)完全隔离。
|
||||
* 若需创建后台管理员,请使用 AdminService,而非此接口。
|
||||
*/
|
||||
public UserAccount adminCreateUser(String username, String password) {
|
||||
validateUsername(username);
|
||||
validatePassword(password, username);
|
||||
UserAccount account = createAccountInternal(username, password);
|
||||
auditService.record("ADMIN", null, AuditService.ACTION_USER_CREATED, account.getUserId(),
|
||||
AuditService.RESULT_SUCCESS, null,
|
||||
"管理员创建账号: " + username);
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账号,同时吊销其全部会话。
|
||||
*/
|
||||
public void deleteUser(String userId) {
|
||||
UserAccount account = requireUser(userId);
|
||||
revokeAllSessions(userId, "账号已被删除");
|
||||
sessionStore.deleteByPrincipal(userId);
|
||||
userStore.delete(userId);
|
||||
logger.info("账号 {} ({}) 已被删除", account.getUsername(), userId);
|
||||
auditService.record("ADMIN", null, AuditService.ACTION_USER_DELETED, userId,
|
||||
AuditService.RESULT_SUCCESS, null, "删除账号: " + account.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员重置密码:无需原密码,成功后强制该账号全部会话下线。
|
||||
*/
|
||||
public void adminResetPassword(String userId, String newPassword) {
|
||||
UserAccount account = requireUser(userId);
|
||||
validatePassword(newPassword, account.getUsername());
|
||||
account.pushPasswordHistory(account.getPasswordHash());
|
||||
account.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
account.setPasswordChangedAt(Instant.now());
|
||||
account.setFailedAttempts(0);
|
||||
account.setLockedUntil(null);
|
||||
userStore.save(account);
|
||||
revokeAllSessions(userId, "密码已被管理员重置,请重新登录");
|
||||
auditService.record("ADMIN", null, AuditService.ACTION_PASSWORD_CHANGED, userId,
|
||||
AuditService.RESULT_SUCCESS, null, "管理员重置密码: " + account.getUsername());
|
||||
}
|
||||
|
||||
// ==================== TOTP 双因子 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.ttstd.signaling.service;
|
||||
|
||||
import com.ttstd.signaling.model.AccountStatus;
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
import com.ttstd.signaling.security.AuthException;
|
||||
import com.ttstd.signaling.security.JwtService;
|
||||
import com.ttstd.signaling.security.SecurityProperties;
|
||||
import com.ttstd.signaling.security.TokenUtils;
|
||||
import com.ttstd.signaling.store.AdminStore;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 后台管理员服务:与 {@link AccountService}(主控端用户)完全独立。
|
||||
*
|
||||
* <ul>
|
||||
* <li>使用单独的 {@link AdminStore} 与数据库表 admin_account;</li>
|
||||
* <li>登录签发独立用途(admin)的 JWT,前缀为 {@code admt_};</li>
|
||||
* <li>不共享用户的注册/绑定/设备逻辑。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class AdminService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AdminService.class);
|
||||
|
||||
/** 管理员令牌用途标识,与用户 access/refresh/device 令牌隔离。 */
|
||||
public static final String TOKEN_PURPOSE = "admin";
|
||||
/** 令牌前缀,便于前端区分与排查。 */
|
||||
public static final String TOKEN_PREFIX = "admt_";
|
||||
|
||||
private static final Pattern ADMIN_USERNAME_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9_.\\-]{3,32}$");
|
||||
|
||||
private final AdminStore adminStore;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final SecurityProperties properties;
|
||||
|
||||
@Autowired
|
||||
public AdminService(AdminStore adminStore,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
SecurityProperties properties) {
|
||||
this.adminStore = adminStore;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动引导:若管理员表为空,则依据 application.yml 的
|
||||
* security.admin.bootstrap-username / bootstrap-password 创建初始管理员。
|
||||
* 若未配置,则记录告警日志(不会抛异常,避免阻断启动)。
|
||||
*/
|
||||
@jakarta.annotation.PostConstruct
|
||||
void bootstrap() {
|
||||
String username = properties.getAdmin() != null
|
||||
? properties.getAdmin().getBootstrapUsername() : null;
|
||||
String password = properties.getAdmin() != null
|
||||
? properties.getAdmin().getBootstrapPassword() : null;
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
if (adminStore.count() == 0) {
|
||||
logger.warn("后台管理员表为空,且未配置 security.admin.bootstrap-username / "
|
||||
+ "bootstrap-password。请通过管理接口或环境变量创建首个管理员,否则无法登录后台。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
AdminAccount existing = adminStore.findByUsername(username).orElse(null);
|
||||
if (existing != null) {
|
||||
// 已存在同名管理员:保证其密码与配置的引导密码一致,
|
||||
// 避免早期手动创建的账号与配置不一致导致网页端无法登录。
|
||||
if (!passwordEncoder.matches(password, existing.getPasswordHash())) {
|
||||
existing.setPasswordHash(passwordEncoder.encode(password));
|
||||
existing.setUpdatedAt(java.time.Instant.now());
|
||||
adminStore.save(existing);
|
||||
logger.info("已将引导管理员 {} 的密码重置为 application.yml 配置值", username);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (adminStore.count() > 0) {
|
||||
return;
|
||||
}
|
||||
createAdminInternal(username, password, username);
|
||||
logger.info("已根据 application.yml 创建初始后台管理员: {}", username);
|
||||
}
|
||||
|
||||
// ==================== 登录 ====================
|
||||
|
||||
public Map<String, Object> login(String username, String password) {
|
||||
if (username == null || password == null) {
|
||||
throw AuthException.badRequest("用户名或密码缺失");
|
||||
}
|
||||
AdminAccount admin = adminStore.findByUsername(username)
|
||||
.orElseThrow(() -> AuthException.unauthorized("用户名或密码错误"));
|
||||
if (admin.isDisabled()) {
|
||||
throw AuthException.forbidden("该管理员已被禁用", "admin disabled");
|
||||
}
|
||||
if (!passwordEncoder.matches(password, admin.getPasswordHash())) {
|
||||
throw AuthException.unauthorized("用户名或密码错误");
|
||||
}
|
||||
admin.setLastLoginAt(Instant.now());
|
||||
adminStore.save(admin);
|
||||
return issueToken(admin);
|
||||
}
|
||||
|
||||
/** 校验管理员令牌,返回声明。令牌需以 {@code admt_} 前缀。 */
|
||||
public Map<String, Object> verifyToken(String rawToken) {
|
||||
String token = stripPrefix(rawToken);
|
||||
Map<String, Object> claims = jwtService.verify(token, TOKEN_PURPOSE);
|
||||
String adminId = JwtService.claimAsString(claims, "sub");
|
||||
AdminAccount admin = adminStore.findById(adminId)
|
||||
.orElseThrow(() -> AuthException.unauthorized("管理员不存在或已被删除"));
|
||||
if (admin.isDisabled()) {
|
||||
throw AuthException.forbidden("该管理员已被禁用", "admin disabled");
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
public String currentAdminId(String rawToken) {
|
||||
Map<String, Object> claims = verifyToken(rawToken);
|
||||
return JwtService.claimAsString(claims, "sub");
|
||||
}
|
||||
|
||||
private Map<String, Object> issueToken(AdminAccount admin) {
|
||||
long ttl = properties.getAdmin() != null
|
||||
? properties.getAdmin().getTokenTtlSeconds() : 3600L;
|
||||
if (ttl <= 0) {
|
||||
ttl = 3600L;
|
||||
}
|
||||
String jwt = jwtService.issue(admin.getAdminId(), TOKEN_PURPOSE, ttl, 0, null);
|
||||
Map<String, Object> result = new java.util.HashMap<>();
|
||||
result.put("token", TOKEN_PREFIX + jwt);
|
||||
result.put("tokenType", "Bearer");
|
||||
result.put("expiresIn", ttl);
|
||||
result.put("adminId", admin.getAdminId());
|
||||
result.put("username", admin.getUsername());
|
||||
result.put("displayName", admin.getDisplayName());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 管理员管理 ====================
|
||||
|
||||
public List<AdminAccount> listAdmins() {
|
||||
return adminStore.findAll();
|
||||
}
|
||||
|
||||
public AdminAccount createAdmin(String username, String password, String displayName) {
|
||||
validateUsername(username);
|
||||
validatePassword(password);
|
||||
if (adminStore.existsByUsername(username)) {
|
||||
throw AuthException.badRequest("管理员用户名已被占用");
|
||||
}
|
||||
return createAdminInternal(username, password, displayName);
|
||||
}
|
||||
|
||||
private AdminAccount createAdminInternal(String username, String password, String displayName) {
|
||||
String adminId = "adm_" + TokenUtils.randomBase62(20);
|
||||
AdminAccount admin = new AdminAccount(adminId, username,
|
||||
passwordEncoder.encode(password));
|
||||
admin.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
||||
admin.setStatus(AccountStatus.ACTIVE);
|
||||
admin.setCreatedAt(Instant.now());
|
||||
adminStore.save(admin);
|
||||
logger.info("后台管理员已创建: {} ({})", username, adminId);
|
||||
return admin;
|
||||
}
|
||||
|
||||
public void disableAdmin(String adminId) {
|
||||
guardNotLastAdmin(adminId);
|
||||
AdminAccount admin = requireAdmin(adminId);
|
||||
admin.setStatus(AccountStatus.DISABLED);
|
||||
admin.setUpdatedAt(Instant.now());
|
||||
adminStore.save(admin);
|
||||
logger.info("后台管理员已禁用: {}", adminId);
|
||||
}
|
||||
|
||||
public void enableAdmin(String adminId) {
|
||||
AdminAccount admin = requireAdmin(adminId);
|
||||
admin.setStatus(AccountStatus.ACTIVE);
|
||||
admin.setUpdatedAt(Instant.now());
|
||||
adminStore.save(admin);
|
||||
logger.info("后台管理员已启用: {}", adminId);
|
||||
}
|
||||
|
||||
public void deleteAdmin(String adminId) {
|
||||
guardNotLastAdmin(adminId);
|
||||
requireAdmin(adminId);
|
||||
adminStore.delete(adminId);
|
||||
logger.info("后台管理员已删除: {}", adminId);
|
||||
}
|
||||
|
||||
public void resetPassword(String adminId, String newPassword) {
|
||||
validatePassword(newPassword);
|
||||
AdminAccount admin = requireAdmin(adminId);
|
||||
admin.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
admin.setUpdatedAt(Instant.now());
|
||||
adminStore.save(admin);
|
||||
logger.info("后台管理员密码已重置: {}", adminId);
|
||||
}
|
||||
|
||||
private AdminAccount requireAdmin(String adminId) {
|
||||
return adminStore.findById(adminId)
|
||||
.orElseThrow(() -> AuthException.badRequest("管理员不存在"));
|
||||
}
|
||||
|
||||
private void guardNotLastAdmin(String adminId) {
|
||||
long active = adminStore.findAll().stream()
|
||||
.filter(a -> !a.isDisabled())
|
||||
.count();
|
||||
AdminAccount target = adminStore.findById(adminId).orElse(null);
|
||||
if (target != null && !target.isDisabled() && active <= 1) {
|
||||
throw AuthException.forbidden("至少需保留一名启用状态的管理员", "last admin");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateUsername(String username) {
|
||||
if (username == null || !ADMIN_USERNAME_PATTERN.matcher(username).matches()) {
|
||||
throw AuthException.badRequest("管理员用户名需为 3-32 位字母、数字、下划线、点或连字符");
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePassword(String password) {
|
||||
if (password == null || password.length() < 8 || password.length() > 128) {
|
||||
throw AuthException.badRequest("密码长度需为 8-128 位");
|
||||
}
|
||||
int classes = 0;
|
||||
if (password.matches(".*[a-z].*")) classes++;
|
||||
if (password.matches(".*[A-Z].*")) classes++;
|
||||
if (password.matches(".*\\d.*")) classes++;
|
||||
if (password.matches(".*[^a-zA-Z0-9].*")) classes++;
|
||||
if (classes < 2) {
|
||||
throw AuthException.badRequest("密码需至少包含大小写字母、数字、符号中的两类");
|
||||
}
|
||||
}
|
||||
|
||||
private static String stripPrefix(String rawToken) {
|
||||
if (rawToken == null) {
|
||||
return null;
|
||||
}
|
||||
if (rawToken.startsWith(TOKEN_PREFIX)) {
|
||||
return rawToken.substring(TOKEN_PREFIX.length());
|
||||
}
|
||||
return rawToken;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
package com.ttstd.signaling.service;
|
||||
|
||||
import com.ttstd.signaling.model.AuditLog;
|
||||
import com.ttstd.signaling.repository.AuditLogRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 安全审计日志。
|
||||
*
|
||||
* <p>记录登录、激活、封禁、踢线等安全敏感操作,用于事后追溯。
|
||||
* 当前为内存环形缓冲实现(保留最近 N 条),同时写入 SLF4J 便于落盘采集;
|
||||
* 后续可替换为写入 {@code audit_log} 表。
|
||||
* 记录持久化到 {@code audit_log} 表,同时写入 SLF4J 便于日志采集。
|
||||
*
|
||||
* <p>写入前会对敏感信息做脱敏,避免日志泄露凭据。
|
||||
* <p>写入前会对敏感信息做脱敏与长度截断,避免日志泄露凭据。
|
||||
*/
|
||||
@Service
|
||||
public class AuditService {
|
||||
|
||||
private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");
|
||||
private static final int MAX_ENTRIES = 5000;
|
||||
private static final Logger logger = LoggerFactory.getLogger(AuditService.class);
|
||||
|
||||
/** 审计动作常量 */
|
||||
public static final String ACTION_LOGIN = "LOGIN";
|
||||
@@ -41,14 +42,21 @@ public class AuditService {
|
||||
public static final String ACTION_BAN = "BAN";
|
||||
public static final String ACTION_UNBAN = "UNBAN";
|
||||
public static final String ACTION_KICK = "KICK";
|
||||
public static final String ACTION_USER_CREATED = "USER_CREATED";
|
||||
public static final String ACTION_USER_DELETED = "USER_DELETED";
|
||||
public static final String ACTION_PROVISION = "DEVICE_PROVISION";
|
||||
public static final String ACTION_DEVICE_TOKEN = "DEVICE_TOKEN";
|
||||
public static final String ACTION_DEVICE_DISABLED = "DEVICE_DISABLED";
|
||||
public static final String ACTION_DEVICE_ENABLED = "DEVICE_ENABLED";
|
||||
public static final String ACTION_DEVICE_CREATED = "DEVICE_CREATED";
|
||||
public static final String ACTION_DEVICE_DELETED = "DEVICE_DELETED";
|
||||
public static final String ACTION_DEVICE_KICK = "DEVICE_KICK";
|
||||
public static final String ACTION_BIND = "DEVICE_BIND";
|
||||
public static final String ACTION_UNBIND = "DEVICE_UNBIND";
|
||||
public static final String ACTION_BLACKLIST_ADD = "DEVICE_BLACKLIST_ADD";
|
||||
public static final String ACTION_BLACKLIST_REMOVE = "DEVICE_BLACKLIST_REMOVE";
|
||||
public static final String ACTION_ALLOWLIST_ADD = "DEVICE_ALLOWLIST_ADD";
|
||||
public static final String ACTION_ALLOWLIST_REMOVE = "DEVICE_ALLOWLIST_REMOVE";
|
||||
public static final String ACTION_OFFER_BLOCKED = "OFFER_BLOCKED";
|
||||
public static final String ACTION_PAIRING_GENERATED = "PAIRING_GENERATED";
|
||||
public static final String ACTION_PAIRING_REDEEMED = "PAIRING_REDEEMED";
|
||||
@@ -57,7 +65,7 @@ public class AuditService {
|
||||
public static final String RESULT_FAILURE = "FAILURE";
|
||||
|
||||
/**
|
||||
* 单条审计记录。
|
||||
* 单条审计记录的对外视图。
|
||||
*/
|
||||
public record AuditEntry(
|
||||
long id,
|
||||
@@ -69,27 +77,55 @@ public class AuditService {
|
||||
String ip,
|
||||
String detail,
|
||||
long timestamp) {
|
||||
|
||||
static AuditEntry from(AuditLog log) {
|
||||
return new AuditEntry(
|
||||
log.getId() == null ? 0L : log.getId(),
|
||||
log.getActorType(),
|
||||
log.getActorId(),
|
||||
log.getAction(),
|
||||
log.getTargetId(),
|
||||
log.getResult(),
|
||||
log.getIp(),
|
||||
log.getDetail(),
|
||||
log.getCreatedAt().toEpochMilli());
|
||||
}
|
||||
}
|
||||
|
||||
private final Deque<AuditEntry> entries = new ConcurrentLinkedDeque<>();
|
||||
private final AtomicLong sequence = new AtomicLong();
|
||||
private final AuditLogRepository repository;
|
||||
|
||||
/** 带持久化的标准构造(由 Spring 注入)。 */
|
||||
public AuditService(AuditLogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 无参构造:用于不依赖数据库的单元测试。此时审计仅写入 SLF4J,不落库。
|
||||
* 与线上"写库失败仅记日志"的容错策略保持一致。
|
||||
*/
|
||||
public AuditService() {
|
||||
this.repository = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一条审计记录。
|
||||
*
|
||||
* <p>使用独立事务,确保调用方业务回滚时审计记录仍然保留;
|
||||
* 审计写库失败不得影响主流程,因此异常仅记录日志。
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void record(String actorType, String actorId, String action,
|
||||
String targetId, String result, String ip, String detail) {
|
||||
AuditEntry entry = new AuditEntry(
|
||||
sequence.incrementAndGet(),
|
||||
actorType, actorId, action, targetId, result, ip,
|
||||
truncate(detail),
|
||||
Instant.now().toEpochMilli());
|
||||
|
||||
entries.addFirst(entry);
|
||||
// 环形缓冲:超出上限时丢弃最旧记录
|
||||
while (entries.size() > MAX_ENTRIES) {
|
||||
entries.pollLast();
|
||||
}
|
||||
|
||||
String safeDetail = truncate(detail);
|
||||
auditLogger.info("actor={}:{} action={} target={} result={} ip={} detail={}",
|
||||
actorType, actorId, action, targetId, result, ip, entry.detail());
|
||||
actorType, actorId, action, targetId, result, ip, safeDetail);
|
||||
try {
|
||||
if (repository != null) {
|
||||
repository.save(new AuditLog(actorType, actorId, action, targetId, result, ip, safeDetail));
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
logger.warn("审计日志写入失败: action={} actor={} 原因={}", action, actorId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public void recordUser(String userId, String action, String result, String ip, String detail) {
|
||||
@@ -105,29 +141,39 @@ public class AuditService {
|
||||
}
|
||||
|
||||
/** 查询最近的审计记录。 */
|
||||
@Transactional(readOnly = true)
|
||||
public List<AuditEntry> recent(int limit) {
|
||||
List<AuditEntry> result = new ArrayList<>(Math.min(limit, entries.size()));
|
||||
for (AuditEntry entry : entries) {
|
||||
if (result.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
result.add(entry);
|
||||
}
|
||||
return result;
|
||||
return search(null, null, null, 0, limit).getContent();
|
||||
}
|
||||
|
||||
/** 按主体过滤审计记录。 */
|
||||
@Transactional(readOnly = true)
|
||||
public List<AuditEntry> byActor(String actorId, int limit) {
|
||||
List<AuditEntry> result = new ArrayList<>();
|
||||
for (AuditEntry entry : entries) {
|
||||
if (result.size() >= limit) {
|
||||
break;
|
||||
}
|
||||
if (actorId.equals(entry.actorId())) {
|
||||
result.add(entry);
|
||||
}
|
||||
return search(actorId, null, null, 0, limit).getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件分页查询,空白参数视为不过滤。
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<AuditEntry> search(String actorId, String action, String result, int page, int size) {
|
||||
if (repository == null) {
|
||||
return org.springframework.data.domain.Page.empty();
|
||||
}
|
||||
return result;
|
||||
Page<AuditLog> found = repository.search(
|
||||
blankToNull(actorId), blankToNull(action), blankToNull(result),
|
||||
PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), 1000)));
|
||||
return found.map(AuditEntry::from);
|
||||
}
|
||||
|
||||
/** 清理早于指定时间的历史审计日志。 */
|
||||
@Transactional
|
||||
public int purgeBefore(Instant before) {
|
||||
return repository.deleteOlderThan(before);
|
||||
}
|
||||
|
||||
private static String blankToNull(String value) {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
|
||||
private static String truncate(String detail) {
|
||||
|
||||
@@ -183,7 +183,7 @@ public class DeviceIdentityService {
|
||||
deviceStore.save(device);
|
||||
|
||||
return new AuthPrincipal(PrincipalType.DEVICE, deviceUid, deviceUid,
|
||||
DeviceType.CONTROLLED, null, device.getModel(), false);
|
||||
DeviceType.CONTROLLED, null, device.getModel());
|
||||
}
|
||||
|
||||
private void assertDeviceUsable(DeviceAccount device) {
|
||||
@@ -223,6 +223,29 @@ public class DeviceIdentityService {
|
||||
logger.info("设备 {} 已启用", deviceUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制设备下线:递增令牌版本使现有令牌立即失效,并断开在线连接。
|
||||
*
|
||||
* <p>与 {@link #disable} 的区别是不改变账号状态,设备可用新令牌重新上线。
|
||||
*/
|
||||
public void kick(String deviceUid, String reason) {
|
||||
DeviceAccount device = requireDevice(deviceUid);
|
||||
device.setTokenVersion(device.getTokenVersion() + 1);
|
||||
deviceStore.save(device);
|
||||
notifyRevoked(deviceUid, reason == null ? "设备已被强制下线" : reason);
|
||||
logger.info("设备 {} 已被强制下线:{}", deviceUid, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备记录,并断开其在线连接。
|
||||
*/
|
||||
public void delete(String deviceUid) {
|
||||
DeviceAccount device = requireDevice(deviceUid);
|
||||
deviceStore.delete(deviceUid);
|
||||
notifyRevoked(deviceUid, "设备已被删除");
|
||||
logger.info("设备 {} (sn={}) 已被删除", deviceUid, device.maskedSn());
|
||||
}
|
||||
|
||||
public DeviceAccount requireDevice(String deviceUid) {
|
||||
return deviceStore.findByDeviceUid(deviceUid)
|
||||
.orElseThrow(() -> AuthException.badRequest("设备不存在"));
|
||||
@@ -258,6 +281,20 @@ public class DeviceIdentityService {
|
||||
return Set.copyOf(deviceStore.listAllowed().stream().map(DeviceAllowlist::getSn).toList());
|
||||
}
|
||||
|
||||
/** 从 SN 白名单移除,返回是否实际删除。 */
|
||||
public boolean removeFromAllowlist(String sn) {
|
||||
if (sn == null || sn.isBlank()) {
|
||||
throw AuthException.badRequest("SN 不能为空");
|
||||
}
|
||||
String trimmed = sn.trim();
|
||||
if (!deviceStore.isSnAllowed(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
deviceStore.removeAllowedSn(trimmed);
|
||||
logger.info("SN 已移出白名单: {}", DeviceAccount.maskSn(trimmed));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void notifyRevoked(String deviceUid, String reason) {
|
||||
AccountService.SessionRevocationListener listener = this.revocationListener;
|
||||
if (listener != null) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.ttstd.signaling.store;
|
||||
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 后台管理员存储抽象。与 {@code UserStore} 互不耦合,JPA 与内存两套实现可切换。
|
||||
*/
|
||||
public interface AdminStore {
|
||||
|
||||
AdminAccount save(AdminAccount admin);
|
||||
|
||||
Optional<AdminAccount> findById(String adminId);
|
||||
|
||||
Optional<AdminAccount> findByUsername(String username);
|
||||
|
||||
boolean existsByUsername(String username);
|
||||
|
||||
boolean existsById(String adminId);
|
||||
|
||||
List<AdminAccount> findAll();
|
||||
|
||||
long count();
|
||||
|
||||
void delete(String adminId);
|
||||
}
|
||||
@@ -16,7 +16,13 @@ public interface DeviceStore {
|
||||
DeviceAccount save(DeviceAccount account);
|
||||
List<DeviceAccount> listAll();
|
||||
|
||||
/** 删除设备。设备不存在时静默返回。 */
|
||||
void delete(String deviceUid);
|
||||
|
||||
boolean isSnAllowed(String sn);
|
||||
void addAllowedSn(DeviceAllowlist entry);
|
||||
List<DeviceAllowlist> listAllowed();
|
||||
|
||||
/** 从 SN 白名单移除条目。 */
|
||||
void removeAllowedSn(String sn);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ttstd.signaling.store;
|
||||
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 内存实现:不依赖数据库,用于测试与本地演示(storage.mode=memory)。 */
|
||||
@Component
|
||||
@Profile("memory")
|
||||
public class InMemoryAdminStore implements AdminStore {
|
||||
|
||||
private final ConcurrentMap<String, AdminAccount> byId = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, String> usernameToId = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public synchronized AdminAccount save(AdminAccount admin) {
|
||||
byId.put(admin.getAdminId(), admin);
|
||||
if (admin.getUsername() != null) {
|
||||
usernameToId.put(admin.getUsername().toLowerCase(java.util.Locale.ROOT), admin.getAdminId());
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findById(String adminId) {
|
||||
return Optional.ofNullable(byId.get(adminId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findByUsername(String username) {
|
||||
if (username == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String id = usernameToId.get(username.toLowerCase(java.util.Locale.ROOT));
|
||||
return Optional.ofNullable(id == null ? null : byId.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByUsername(String username) {
|
||||
if (username == null) {
|
||||
return false;
|
||||
}
|
||||
return usernameToId.containsKey(username.toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String adminId) {
|
||||
return byId.containsKey(adminId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AdminAccount> findAll() {
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return byId.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void delete(String adminId) {
|
||||
AdminAccount removed = byId.remove(adminId);
|
||||
if (removed != null && removed.getUsername() != null) {
|
||||
usernameToId.remove(removed.getUsername().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,14 @@ public class InMemoryDeviceStore implements DeviceStore {
|
||||
return List.copyOf(byUid.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void delete(String deviceUid) {
|
||||
DeviceAccount removed = byUid.remove(deviceUid);
|
||||
if (removed != null) {
|
||||
snToUid.remove(removed.getSn());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean isSnAllowed(String sn) {
|
||||
return allowlist.containsKey(sn);
|
||||
@@ -62,4 +70,9 @@ public class InMemoryDeviceStore implements DeviceStore {
|
||||
public synchronized List<DeviceAllowlist> listAllowed() {
|
||||
return List.copyOf(allowlist.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void removeAllowedSn(String sn) {
|
||||
allowlist.remove(sn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,12 @@ public class InMemoryUserStore implements UserStore {
|
||||
public synchronized List<UserAccount> listAll() {
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void delete(String userId) {
|
||||
UserAccount removed = byId.remove(userId);
|
||||
if (removed != null) {
|
||||
usernameToId.remove(removed.getUsername());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ttstd.signaling.store;
|
||||
|
||||
import com.ttstd.signaling.model.AdminAccount;
|
||||
import com.ttstd.signaling.repository.AdminAccountRepository;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** JPA 实现:管理员账号持久化到 MySQL。仅在 storage.mode=jpa(默认)时生效。 */
|
||||
@Component
|
||||
@Profile("!memory")
|
||||
public class JpaAdminStore implements AdminStore {
|
||||
|
||||
private final AdminAccountRepository repository;
|
||||
|
||||
public JpaAdminStore(AdminAccountRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminAccount save(AdminAccount admin) {
|
||||
return repository.save(admin);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findById(String adminId) {
|
||||
return repository.findById(adminId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<AdminAccount> findByUsername(String username) {
|
||||
return repository.findByUsername(username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByUsername(String username) {
|
||||
return repository.existsByUsername(username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsById(String adminId) {
|
||||
return repository.existsById(adminId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AdminAccount> findAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long count() {
|
||||
return repository.count();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String adminId) {
|
||||
repository.deleteById(adminId);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,11 @@ public class JpaDeviceStore implements DeviceStore {
|
||||
return deviceRepository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String deviceUid) {
|
||||
deviceRepository.deleteById(deviceUid);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSnAllowed(String sn) {
|
||||
return allowlistRepository.existsBySn(sn);
|
||||
@@ -63,4 +68,9 @@ public class JpaDeviceStore implements DeviceStore {
|
||||
public List<DeviceAllowlist> listAllowed() {
|
||||
return allowlistRepository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAllowedSn(String sn) {
|
||||
allowlistRepository.deleteById(sn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,9 @@ public class JpaUserStore implements UserStore {
|
||||
public List<UserAccount> listAll() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String userId) {
|
||||
repository.deleteById(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,7 @@ public interface UserStore {
|
||||
boolean existsByUsername(String username);
|
||||
UserAccount save(UserAccount account);
|
||||
List<UserAccount> listAll();
|
||||
|
||||
/** 删除账号。账号不存在时静默返回。 */
|
||||
void delete(String userId);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@ spring:
|
||||
# Redis:在线状态、频控计数、nonce 去重、踢线广播
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
host: ${REDIS_HOST:175.178.213.60}
|
||||
port: ${REDIS_PORT:26379}
|
||||
password: ${REDIS_PASSWORD:fanhuitong}
|
||||
database: ${REDIS_DATABASE:15}
|
||||
timeout: 3000ms
|
||||
lettuce:
|
||||
pool:
|
||||
@@ -52,16 +52,11 @@ redis:
|
||||
online-key-prefix: "webrtc:online:"
|
||||
flush-key-prefix: "webrtc:flush:"
|
||||
|
||||
# 后台管理接口鉴权令牌;可通过环境变量 ADMIN_TOKEN 覆盖。
|
||||
# WebRTCSignalServerWeb 登录时使用的默认令牌即为此值。
|
||||
admin:
|
||||
token: ${ADMIN_TOKEN:webrtc-admin-token}
|
||||
|
||||
security:
|
||||
jwt:
|
||||
# 【生产环境必须通过环境变量 JWT_SECRET 覆盖】留空时服务启动会随机生成密钥,
|
||||
# 随机密钥会导致服务重启后所有已签发 token 失效,且多实例部署无法互认。
|
||||
secret: ${JWT_SECRET:}
|
||||
# 开发环境固定默认密钥(至少 32 字节),避免随机密钥导致重启后所有 token 失效。
|
||||
# 【生产环境必须通过环境变量 JWT_SECRET 覆盖为强随机值】
|
||||
secret: ${JWT_SECRET:dev-only-fixed-jwt-secret-please-change-32b+}
|
||||
issuer: webrtc-signal-server
|
||||
# 访问令牌有效期(秒),默认 15 分钟
|
||||
access-token-ttl-seconds: 900
|
||||
@@ -75,7 +70,7 @@ security:
|
||||
device:
|
||||
# 设备激活(provision)共享密钥:被控端为系统签名应用,内置同一密钥用于 HMAC 签名。
|
||||
# 【生产环境必须通过环境变量 DEVICE_PROVISION_SECRET 覆盖】
|
||||
provision-secret: ${DEVICE_PROVISION_SECRET:}
|
||||
provision-secret: ${DEVICE_PROVISION_SECRET:dev-device-provision-secret-change-me}
|
||||
# 激活请求时间戳允许的偏移(秒),用于防重放
|
||||
provision-skew-seconds: 300
|
||||
# 是否启用 SN 白名单:启用后仅允许已导入白名单的 SN 激活
|
||||
@@ -89,9 +84,16 @@ security:
|
||||
lock-duration-seconds: 900
|
||||
# 单账号最大并发会话数,超出时踢掉最旧会话
|
||||
max-concurrent-sessions: 5
|
||||
# 首次启动时自动创建的初始管理员账号(仅当账号不存在时创建)
|
||||
bootstrap-username: ${BOOTSTRAP_ADMIN_USERNAME:admin}
|
||||
bootstrap-password: ${BOOTSTRAP_ADMIN_PASSWORD:}
|
||||
|
||||
# 后台管理员配置(独立于普通用户体系,使用单独数据库表 admin_account)
|
||||
admin:
|
||||
# 初始管理员引导:当管理员表为空时,按以下用户名/密码自动创建首个管理员。
|
||||
# 可通过环境变量 ADMIN_BOOTSTRAP_USERNAME / ADMIN_BOOTSTRAP_PASSWORD 覆盖。
|
||||
# 若两者均留空,则不自动创建,需通过管理接口建立首个管理员。
|
||||
bootstrap-username: ${ADMIN_BOOTSTRAP_USERNAME:admin}
|
||||
bootstrap-password: ${ADMIN_BOOTSTRAP_PASSWORD:admin123456}
|
||||
# 管理员令牌有效期(秒),默认 1 小时
|
||||
token-ttl-seconds: 3600
|
||||
|
||||
websocket:
|
||||
# WebSocket 允许的来源,逗号分隔;生产环境应收敛为具体域名
|
||||
|
||||
Reference in New Issue
Block a user