topfans/backend/gateway/controller/auth_controller.go
zerosaturation 293c7b14ae fix(security): JWT key governance — MustInit fail-fast + atomic.Value
- pkg/jwt: 删 public SetSecret; 加 MustInit(secret string) 启动时强制注入,
  缺/为空/等于弱默认值时返回 error(运行期不可再改); 密钥用 atomic.Value
  存 []byte,所有读走 mustSecret() 原子 Load,消除 SetSecret/ParseToken 并发
  data race(go test -race 零告警)。
- gateway main + auth_provider: 启动时 MustInit 读 JWT_SECRET env,失败 fatal。
- scripts/loadgen/seed/tokens: 同步 MustInit。
- .env.example: JWT_SECRET 改为 ≥32 字节 base64 示例(原为空,被 MustInit
  立即拒);注释提示生产 MUST replace。
- 测试: 4 个 MustInit 行为 + 1 个 50-goroutine race 覆盖。
- 行为变更: 任何 .env 缺 JWT_SECRET 或用占位 secret 的服务,启动会 panic
  (这是 fail-fast 期望行为);其余 4 个 .env 文件占位由 ops 单独轮换。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 18:50:12 +08:00

561 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"context"
"net/http"
"strconv"
"dubbo.apache.org/dubbo-go/v3/client"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"github.com/gin-gonic/gin"
"github.com/topfans/backend/gateway/dto"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/gateway/pkg/starcache"
"github.com/topfans/backend/pkg/logger"
"google.golang.org/grpc/codes"
pb "github.com/topfans/backend/pkg/proto/user"
"go.uber.org/zap"
)
// AuthController 认证控制器
type AuthController struct {
userServiceClient pb.UserSocialService
starCache *starcache.Cache
}
// pbError 用于包装 proto 错误消息
type pbError struct {
message string
}
func (e *pbError) Error() string {
return e.message
}
// NewAuthController 创建认证控制器
//
// starCache 用于 Register/Login 两个公开入口的 star 解析:
// 取代原先每次都直接 RPC GetFanIdentities 拉一遍可选身份列表。
func NewAuthController(dubboClient *client.Client, starCache *starcache.Cache) (*AuthController, error) {
svc, err := pb.NewUserSocialService(dubboClient)
if err != nil {
return nil, err
}
return &AuthController{
userServiceClient: svc,
starCache: starCache,
}, nil
}
// findStar 解析 starID 对应的 *pb.Star。失败仅为 warn,不阻断主流程
// (DTO 转换对 nil star 有防御,Register/Login 仍能成功)。
func (ctrl *AuthController) findStar(ctx context.Context, starID int64) *pb.Star {
star, err := ctrl.starCache.GetStar(ctx, starID)
if err != nil {
logger.Logger.Warn("GetStar cache miss+RPC failed, continuing with nil star",
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil
}
return star
}
// Register 用户注册
// @Summary 用户注册
// @Description 用户注册接口,需要提供手机号、密码、选择明星身份
// @Tags auth
// @Accept json
// @Produce json
// @Param request body pb.RegisterRequest true "注册请求"
// @Success 200 {object} response.Response{data=dto.RegisterResponseDTO}
// @Router /api/v1/auth/register [post]
func (ctrl *AuthController) Register(c *gin.Context) {
var req pb.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid register request", zap.Error(err))
response.BadRequest(c, "请求参数错误")
return
}
logger.Logger.Info("Register request received",
zap.String("mobile", req.Mobile),
zap.Int64("star_id", req.StarId),
)
// 调用 Dubbo 服务(无需 Attachments
ctx := context.Background()
resp, err := ctrl.userServiceClient.Register(ctx, &req)
if err != nil {
logger.Logger.Error("Register failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
star := ctrl.findStar(ctx, req.StarId)
logger.Logger.Info("Register successful",
zap.Int64("user_id", resp.User.Id),
)
// 转换为 DTO 并返回
data := dto.ToRegisterResponseDTO(
resp.AccessToken,
resp.ExpiresIn,
resp.User,
resp.FanProfile,
star,
)
response.Success(c, data)
}
// Login 用户登录
// @Summary 用户登录
// @Description 用户登录接口,需要提供手机号和密码
// @Tags auth
// @Accept json
// @Produce json
// @Param request body pb.LoginRequest true "登录请求"
// @Success 200 {object} response.Response{data=dto.LoginResponseDTO}
// @Router /api/v1/auth/login [post]
func (ctrl *AuthController) Login(c *gin.Context) {
var req pb.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid login request", zap.Error(err))
response.BadRequest(c, "请求参数错误")
return
}
logger.Logger.Info("Login request received",
zap.String("mobile", req.Mobile),
)
// 调用 Dubbo 服务(无需 Attachments
ctx := context.Background()
resp, err := ctrl.userServiceClient.Login(ctx, &req)
if err != nil {
logger.Logger.Error("Login failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
star := ctrl.findStar(ctx, resp.FanProfile.StarId)
logger.Logger.Info("Login successful",
zap.Int64("user_id", resp.User.Id),
)
// 转换为 DTO 并返回
data := dto.ToLoginResponseDTO(
resp.AccessToken,
resp.ExpiresIn,
"", // refresh_token 暂时为空
resp.User,
resp.FanProfile,
star,
)
response.Success(c, data)
}
// RefreshToken 刷新 Token
// @Summary 刷新访问令牌
// @Description 使用当前访问令牌刷新获取新的访问令牌
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/v1/auth/refresh [post]
func (ctrl *AuthController) RefreshToken(c *gin.Context) {
// 从认证中间件获取用户信息
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"code": "UNAUTHORIZED",
"message": "user not authenticated",
})
return
}
starID, _ := c.Get("star_id")
logger.Logger.Info("RefreshToken request received",
zap.Any("user_id", userID),
zap.Any("star_id", starID),
)
// 创建带 Attachments 的 context
// 注意Dubbo Attachments 的值必须是 string 或 []string
ctx := context.Background()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(starID.(int64), 10),
})
// 调用 Dubbo 服务
resp, err := ctrl.userServiceClient.RefreshToken(ctx, &pb.RefreshTokenRequest{})
if err != nil {
logger.Logger.Error("RefreshToken failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": err.Error(),
})
return
}
logger.Logger.Info("RefreshToken successful",
zap.Any("user_id", userID),
)
c.JSON(http.StatusOK, resp)
}
// Logout 用户登出
// @Summary 用户登出
// @Description 使用户访问令牌失效
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/v1/auth/logout [post]
func (ctrl *AuthController) Logout(c *gin.Context) {
// 从认证中间件获取用户信息
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "请先登录")
return
}
logger.Logger.Info("Logout request received",
zap.Any("user_id", userID),
)
// 创建带 Attachments 的 context
// 注意Dubbo Attachments 的值必须是 string 或 []string
ctx := context.Background()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
})
// 调用 Dubbo 服务
_, err := ctrl.userServiceClient.Logout(ctx, &pb.LogoutRequest{})
if err != nil {
logger.Logger.Error("Logout failed", zap.Error(err))
response.HandleError(c, err)
return
}
logger.Logger.Info("Logout successful",
zap.Any("user_id", userID),
)
// 返回空 data
response.Success(c, gin.H{})
}
// ValidateToken 验证 Token用于客户端检查 Token 是否有效)
// @Summary 验证访问令牌
// @Description 验证访问令牌的有效性
// @Tags auth
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body pb.ValidateTokenRequest true "验证请求"
// @Success 200 {object} response.Response
// @Router /api/v1/auth/validate [post]
//
// ★ 本接口已被 AuthMiddleware 保护(鉴权边界审计 §四 P2):
// 路由从公开 /auth 组移到 authProtected(router.go:179-191),
// 调用方必须持有有效、未过期、未在黑名单的 JWT。
// 控制器内部无需再校验 Authorization 头,AuthMiddleware 已做。
func (ctrl *AuthController) ValidateToken(c *gin.Context) {
var req pb.ValidateTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": "BAD_REQUEST",
"message": err.Error(),
})
return
}
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.ValidateToken(ctx, &req)
if err != nil {
logger.Logger.Error("ValidateToken failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"code": "INTERNAL_ERROR",
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// CheckNickname 检查昵称是否已被注册
// @Summary 检查昵称是否被注册
// @Description 检查指定昵称是否已被他人使用
// @Tags auth
// @Accept json
// @Produce json
// @Param request body pb.CheckNicknameRequest true "检查昵称请求"
// @Success 200 {object} response.Response{data=pb.CheckNicknameResponse}
// @Router /api/v1/auth/check-nickname [post]
func (ctrl *AuthController) CheckNickname(c *gin.Context) {
var req pb.CheckNicknameRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid check nickname request", zap.Error(err))
response.BadRequest(c, "请求参数错误")
return
}
// 校验 nickname 不能为空
if req.Nickname == "" {
response.BadRequest(c, "昵称不能为空")
return
}
logger.Logger.Info("CheckNickname request received",
zap.String("nickname", req.Nickname),
)
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.CheckNickname(ctx, &req)
if err != nil {
logger.Logger.Error("CheckNickname failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
logger.Logger.Info("CheckNickname successful",
zap.String("nickname", req.Nickname),
zap.Bool("exists", resp.Exists),
)
response.Success(c, gin.H{
"exists": resp.Exists,
})
}
// SendCode 发送验证码
// @Summary 发送验证码
// @Description 发送手机验证码,用于注册或重置密码
// @Tags auth
// @Accept json
// @Produce json
// @Param request body dto.SendCodeRequest true "发送验证码请求"
// @Success 200 {object} response.Response{data=dto.SendCodeResponse}
// @Router /api/v1/auth/send-code [post]
func (ctrl *AuthController) SendCode(c *gin.Context) {
var req dto.SendCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid send code request", zap.Error(err))
response.BadRequest(c, "参数错误")
return
}
logger.Logger.Info("SendCode request received",
zap.String("mobile", req.Mobile),
zap.String("scene", req.Scene),
)
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.SendCode(ctx, &pb.SendCodeRequest{
Mobile: req.Mobile,
Scene: req.Scene,
})
if err != nil {
logger.Logger.Error("SendCode failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
logger.Logger.Info("SendCode successful",
zap.String("mobile", req.Mobile),
)
response.Success(c, gin.H{
"expires_in": resp.ExpiresIn,
})
}
// VerifyCode 验证验证码
// @Summary 验证验证码
// @Description 验证手机验证码,验证成功后返回 verify_token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body dto.VerifyCodeRequest true "验证验证码请求"
// @Success 200 {object} response.Response{data=dto.VerifyCodeResponse}
// @Router /api/v1/auth/verify-code [post]
func (ctrl *AuthController) VerifyCode(c *gin.Context) {
var req dto.VerifyCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid verify code request", zap.Error(err))
response.BadRequest(c, "参数错误")
return
}
logger.Logger.Info("VerifyCode request received",
zap.String("mobile", req.Mobile),
zap.String("scene", req.Scene),
)
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.VerifyCode(ctx, &pb.VerifyCodeRequest{
Mobile: req.Mobile,
Code: req.Code,
Scene: req.Scene,
})
if err != nil {
logger.Logger.Error("VerifyCode failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
logger.Logger.Info("VerifyCode successful",
zap.String("mobile", req.Mobile),
)
response.Success(c, gin.H{
"verified": resp.Verified,
"verify_token": resp.VerifyToken,
"expires_in": resp.ExpiresIn,
})
}
// ResetPassword 匿名重置密码(忘记密码场景)
// @Summary 匿名重置密码
// @Description 通过手机号+短信验证码(scene=password)+新密码重置密码,无需登录态
// @Tags auth
// @Accept json
// @Produce json
// @Param request body dto.ResetPasswordRequest true "重置密码请求"
// @Success 200 {object} response.Response
// @Router /api/v1/auth/reset-password [post]
func (ctrl *AuthController) ResetPassword(c *gin.Context) {
var req dto.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid reset password request", zap.Error(err))
response.BadRequest(c, "参数错误")
return
}
logger.Logger.Info("ResetPassword request received",
zap.String("mobile", req.Mobile),
)
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.ResetPassword(ctx, &pb.ResetPasswordRequest{
Mobile: req.Mobile,
NewPassword: req.NewPassword,
VerifyToken: req.VerifyToken,
})
if err != nil {
logger.Logger.Error("ResetPassword failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
logger.Logger.Info("ResetPassword successful",
zap.String("mobile", req.Mobile),
)
response.Success(c, gin.H{})
}
// CheckMobile 检查手机号是否已被注册
// @Summary 检查手机号是否被注册
// @Description 检查指定手机号是否已被他人使用
// @Tags auth
// @Accept json
// @Produce json
// @Param request body pb.CheckMobileRequest true "检查手机号请求"
// @Success 200 {object} response.Response{data=pb.CheckMobileResponse}
// @Router /api/v1/auth/check-mobile [post]
func (ctrl *AuthController) CheckMobile(c *gin.Context) {
var req pb.CheckMobileRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Logger.Warn("Invalid check mobile request", zap.Error(err))
response.BadRequest(c, "请求参数错误")
return
}
// 校验 mobile 不能为空
if req.Mobile == "" {
response.BadRequest(c, "手机号不能为空")
return
}
logger.Logger.Info("CheckMobile request received",
zap.String("mobile", req.Mobile),
)
// 调用 Dubbo 服务
ctx := context.Background()
resp, err := ctrl.userServiceClient.CheckMobile(ctx, &req)
if err != nil {
logger.Logger.Error("CheckMobile failed", zap.Error(err))
response.HandleError(c, err)
return
}
// 检查业务错误
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.HandleError(c, &pbError{message: resp.Base.Message})
return
}
logger.Logger.Info("CheckMobile successful",
zap.String("mobile", req.Mobile),
zap.Bool("exists", resp.Exists),
)
response.Success(c, gin.H{
"exists": resp.Exists,
})
}