forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
248 lines
13 KiB
248 lines
13 KiB
package com.mydreamjob.controller;
|
|
|
|
import com.mydreamjob.auth.AuthService;
|
|
import com.mydreamjob.auth.AuthSessionService;
|
|
import com.mydreamjob.auth.InvalidTokenException;
|
|
import com.mydreamjob.auth.LoginRequest;
|
|
import com.mydreamjob.auth.SmsVerificationService;
|
|
import com.mydreamjob.auth.dto.RefreshTokenRequest;
|
|
import com.mydreamjob.auth.dto.TokenPairResponse;
|
|
import com.mydreamjob.auth.dto.PasswordChangeRequest;
|
|
import com.mydreamjob.auth.dto.PasswordLoginRequest;
|
|
import com.mydreamjob.auth.dto.PasswordOperationResponse;
|
|
import com.mydreamjob.auth.dto.PasswordResetRequest;
|
|
import com.mydreamjob.auth.dto.PasswordSetRequest;
|
|
import com.mydreamjob.auth.password.PasswordAuthenticationService;
|
|
import com.mydreamjob.auth.company.CompanyRegistrationRequest;
|
|
import com.mydreamjob.auth.company.CompanyRegistrationResponse;
|
|
import com.mydreamjob.auth.company.CompanyRegistrationService;
|
|
import com.mydreamjob.audit.AuditContext;
|
|
import com.mydreamjob.common.api.ApiResponse;
|
|
import com.mydreamjob.common.exception.BusinessException;
|
|
import com.mydreamjob.common.exception.ErrorCode;
|
|
import com.mydreamjob.security.CurrentUserAccessor;
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
import jakarta.servlet.http.HttpServletRequest;
|
|
import jakarta.validation.ConstraintViolationException;
|
|
import jakarta.validation.Valid;
|
|
import jakarta.validation.constraints.NotBlank;
|
|
import jakarta.validation.constraints.Pattern;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.validation.annotation.Validated;
|
|
import org.springframework.web.bind.annotation.*;
|
|
|
|
import java.util.Map;
|
|
|
|
/**
|
|
* 认证相关接口
|
|
*
|
|
* <p>S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、
|
|
* 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。</p>
|
|
*/
|
|
@Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息")
|
|
@RestController
|
|
@RequestMapping("/auth")
|
|
@RequiredArgsConstructor
|
|
@Validated
|
|
public class AuthController {
|
|
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
|
|
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
|
|
|
|
private final AuthService authService;
|
|
private final SmsVerificationService smsVerificationService;
|
|
private final CurrentUserAccessor currentUserAccessor;
|
|
private final PasswordAuthenticationService passwordAuthenticationService;
|
|
private final CompanyRegistrationService companyRegistrationService;
|
|
|
|
@Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料")
|
|
@PostMapping("/company/register")
|
|
public ResponseEntity<ApiResponse<CompanyRegistrationResponse>> companyRegister(
|
|
@Valid @RequestBody CompanyRegistrationRequest body, HttpServletRequest request) {
|
|
CompanyRegistrationResponse response = companyRegistrationService.register(body, AuditContext.from(request));
|
|
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
|
|
}
|
|
|
|
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
|
|
@PostMapping("/sms/send")
|
|
public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode(
|
|
@RequestParam @NotBlank(message = "phone is required")
|
|
@Pattern(regexp = "[0-9]{11}", message = "phone must contain exactly 11 digits") String phone,
|
|
@RequestParam(defaultValue = "login") String purpose,
|
|
HttpServletRequest request) {
|
|
try {
|
|
smsVerificationService.sendCode(phone, purpose,
|
|
requestIdAccessor.get(request),
|
|
request.getRemoteAddr(),
|
|
request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
Map.of("message", "验证码已发送(Mock)"),
|
|
requestIdAccessor.get(request)));
|
|
} catch (ConstraintViolationException e) {
|
|
return ResponseEntity.badRequest().body(
|
|
ApiResponse.badRequest(e.getMessage(), requestIdAccessor.get(request)));
|
|
} catch (Exception e) {
|
|
return ResponseEntity.badRequest().body(
|
|
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话")
|
|
@PostMapping("/login")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin(
|
|
@Valid @RequestBody LoginRequest body,
|
|
HttpServletRequest request) {
|
|
String phone = body.getPhone();
|
|
String code = body.getCode();
|
|
String purpose = body.getPurpose() == null || body.getPurpose().isBlank()
|
|
? "login" : body.getPurpose();
|
|
try {
|
|
Map<String, Object> result = authService.mockLogin(phone, code, purpose,
|
|
requestIdAccessor.get(request),
|
|
request.getRemoteAddr(),
|
|
request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
|
|
} catch (BusinessException e) {
|
|
// 已注销手机号:保留 HTTP 409 / 业务 code=9303,交由全局异常处理器映射。
|
|
// 其余业务异常(如验证码错误、参数校验)维持既有 400/code=400 行为。
|
|
if (e.getErrorCodeInt() == ErrorCode.ACCOUNT_DELETED.getCode()) {
|
|
throw e;
|
|
}
|
|
return ResponseEntity.badRequest().body(
|
|
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
|
|
} catch (Exception e) {
|
|
return ResponseEntity.badRequest().body(
|
|
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "手机号密码登录", description = "使用手机号和密码登录,返回 Access/Refresh Token;不自动创建账号")
|
|
@PostMapping("/password/login")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> passwordLogin(
|
|
@Valid @RequestBody PasswordLoginRequest body, HttpServletRequest request) {
|
|
try {
|
|
return ResponseEntity.ok(ApiResponse.ok(passwordAuthenticationService.login(
|
|
body.getPhone(), body.getPassword(), requestIdAccessor.get(request),
|
|
request.getRemoteAddr(), request.getHeader("User-Agent")), requestIdAccessor.get(request)));
|
|
} catch (BusinessException e) {
|
|
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
|
|
return ResponseEntity.status(503).body(ApiResponse.error(503,
|
|
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "首次设置密码")
|
|
@PostMapping("/password/set")
|
|
public ResponseEntity<ApiResponse<PasswordOperationResponse>> setPassword(
|
|
@Valid @RequestBody PasswordSetRequest body, HttpServletRequest request) {
|
|
try {
|
|
Long userId = currentUserAccessor.requireUserId();
|
|
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.setPassword(
|
|
userId, body.getSmsCode(), body.getNewPassword(), requestIdAccessor.get(request),
|
|
request.getRemoteAddr(), request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
|
|
requestIdAccessor.get(request)));
|
|
} catch (BusinessException e) {
|
|
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
|
|
return ResponseEntity.status(503).body(ApiResponse.error(503,
|
|
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "修改密码")
|
|
@PostMapping("/password/change")
|
|
public ResponseEntity<ApiResponse<PasswordOperationResponse>> changePassword(
|
|
@Valid @RequestBody PasswordChangeRequest body, HttpServletRequest request) {
|
|
try {
|
|
Long userId = currentUserAccessor.requireUserId();
|
|
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.changePassword(
|
|
userId, body.getCurrentPassword(), body.getNewPassword(), requestIdAccessor.get(request),
|
|
request.getRemoteAddr(), request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
|
|
requestIdAccessor.get(request)));
|
|
} catch (BusinessException e) {
|
|
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
|
|
return ResponseEntity.status(503).body(ApiResponse.error(503,
|
|
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "短信找回并重置密码")
|
|
@PostMapping("/password/reset")
|
|
public ResponseEntity<ApiResponse<PasswordOperationResponse>> resetPassword(
|
|
@Valid @RequestBody PasswordResetRequest body, HttpServletRequest request) {
|
|
try {
|
|
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.resetPassword(
|
|
body.getPhone(), body.getSmsCode(), body.getNewPassword(), requestIdAccessor.get(request),
|
|
request.getRemoteAddr(), request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
|
|
requestIdAccessor.get(request)));
|
|
} catch (BusinessException e) {
|
|
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
|
|
return ResponseEntity.status(503).body(ApiResponse.error(503,
|
|
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "刷新 Token",
|
|
description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效")
|
|
@PostMapping("/refresh")
|
|
public ResponseEntity<ApiResponse<TokenPairResponse>> refresh(
|
|
@Valid @RequestBody RefreshTokenRequest body,
|
|
HttpServletRequest request) {
|
|
try {
|
|
AuthSessionService.SessionTokens tokens = authService.refresh(
|
|
body.getRefreshToken(),
|
|
requestIdAccessor.get(request),
|
|
request.getRemoteAddr(),
|
|
request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(),
|
|
AuthService.EXPIRES_IN_SECONDS),
|
|
requestIdAccessor.get(request)));
|
|
} catch (InvalidTokenException e) {
|
|
// 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。
|
|
return unauthorized(request);
|
|
}
|
|
}
|
|
|
|
@Operation(summary = "登出",
|
|
description = "撤销当前登录会话;需要 Access Token,不接受任何请求体")
|
|
@PostMapping("/logout")
|
|
public ResponseEntity<ApiResponse<Map<String, String>>> logout(HttpServletRequest request) {
|
|
// 身份与会话一律来自通过校验的 Access Token,不接受客户端传入 userId/sessionId。
|
|
Long userId = currentUserAccessor.requireUserId();
|
|
String sessionId = currentUserAccessor.requireSessionId();
|
|
authService.logout(userId, sessionId,
|
|
requestIdAccessor.get(request),
|
|
request.getRemoteAddr(),
|
|
request.getHeader("User-Agent"));
|
|
return ResponseEntity.ok(ApiResponse.ok(
|
|
Map.of("message", "logged out"), requestIdAccessor.get(request)));
|
|
}
|
|
|
|
@Operation(summary = "当前用户信息", description = "需要 JWT 认证")
|
|
@GetMapping("/me")
|
|
public ResponseEntity<ApiResponse<Map<String, Object>>> me(HttpServletRequest request) {
|
|
Long userId = currentUserAccessor.requireUserId();
|
|
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request)));
|
|
}
|
|
|
|
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
|
|
return ResponseEntity.status(401).body(
|
|
ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request)));
|
|
}
|
|
|
|
}
|
|
|