重构项目架构,引入启动页(SplashActivity)检查激活状态,未激活设备引导至激活页(ActivationActivity)完成provision+token流程。集成Retrofit+RxJava3网络层、Room数据库、Lifecycle组件、MMKV等依赖,升级OkHttp/Gson版本,并将构建配置从旧项目全面迁移至新项目结构。
213 lines
9.5 KiB
Java
213 lines
9.5 KiB
Java
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());
|
|
// 便于客户端展示"是否已开启双因子",仅对用户身份有效
|
|
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;
|
|
}
|
|
}
|