feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user