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> 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 body = new LinkedHashMap<>(); body.put("userId", account.getUserId()); body.put("username", account.getUsername()); return ResponseEntity.ok(body); } @PostMapping("/login") public ResponseEntity 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 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> logout(HttpServletRequest httpRequest) { AuthPrincipal principal = requirePrincipal(httpRequest); accountService.logout(principal.sessionId()); return ResponseEntity.ok(Map.of("success", true)); } @PostMapping("/logout-all") public ResponseEntity> logoutAll(HttpServletRequest httpRequest) { AuthPrincipal principal = requirePrincipal(httpRequest); accountService.revokeAllSessions(principal.principalId(), "用户主动退出全部设备"); return ResponseEntity.ok(Map.of("success", true)); } @PostMapping("/change-password") public ResponseEntity> 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> totpSetup(HttpServletRequest httpRequest) { AuthPrincipal principal = requirePrincipal(httpRequest); Map result = accountService.setupTotp(principal.principalId()); Map 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> 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> 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> me(HttpServletRequest httpRequest) { AuthPrincipal principal = requirePrincipal(httpRequest); Map 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()); // 便于客户端展示"是否已开启双因子",仅对用户身份有效 if (principal.isUser()) { body.put("totpEnabled", accountService.requireUser(principal.principalId()).isTotpEnabled()); } return ResponseEntity.ok(body); } /** 查询当前账号的活跃会话列表。 */ @GetMapping("/sessions") public ResponseEntity>> sessions(HttpServletRequest httpRequest) { AuthPrincipal principal = requirePrincipal(httpRequest); List> body = accountService.listSessions(principal.principalId()) .stream() .map(s -> { Map 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; } }