Files
dudu/server/internal/auth/handlers.go
T
wangjia 8266c575aa
ci / server (push) Failing after 10s
ci / design-system (push) Failing after 10s
fix(auth): 固定验证码改为显式 AUTH_EMAIL_DEV_CODE 环境开关(安全评审反馈)
不再从 mailer 运行时类型推导认证材料:固定码仅当显式设置
AUTH_EMAIL_DEV_CODE 时生效(run-dev.sh 设 888888,生产部署不经
该脚本即为随机码),设置时启动打印醒目告警。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:11 +08:00

150 lines
4.7 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 auth
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"dudu/server/internal/store"
"dudu/server/pkg/protocol"
)
// Handlers 认证路由:扫码(5B)、微信 OAuth(5C)、邮箱验证码、logout。
type Handlers struct {
DB *gorm.DB
RDB *redis.Client
JWT *JWT
Wechat WechatClient
Mailer Mailer
// DevCode 非空时邮箱验证码固定为该值(AUTH_EMAIL_DEV_CODE,仅开发脚本设置)。
DevCode string
// QrAuthURL 二维码内容模板(真实环境为微信开放平台授权页,%s 为 state)
QrAuthURL string
}
const qrTTL = 2 * time.Minute
type qrState struct {
Status string `json:"status"` // pending | confirmed
Token string `json:"token,omitempty"`
UserID string `json:"user_id,omitempty"`
Nick string `json:"nick,omitempty"`
}
// CreateQr POST /v1/auth/qr 🔓
func (h *Handlers) CreateQr(c *gin.Context) {
state := uuid.NewString()
b, _ := json.Marshal(qrState{Status: "pending"})
if err := h.RDB.Set(c, store.KeyAuthQr(state), b, qrTTL).Err(); err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
url := h.QrAuthURL
if url == "" {
url = "https://dudu.app/auth/qr/" // mock 占位,接入开放平台后替换
}
c.JSON(http.StatusOK, protocol.AuthQrResponse{State: state, QrURL: url + state})
}
// PollQr GET /v1/auth/qr/:state 🔓(桌面端 1s 轮询)
func (h *Handlers) PollQr(c *gin.Context) {
b, err := h.RDB.Get(c, store.KeyAuthQr(c.Param("state"))).Bytes()
if err == redis.Nil {
c.JSON(http.StatusOK, protocol.AuthQrStatusResponse{Status: "expired"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
var s qrState
_ = json.Unmarshal(b, &s)
resp := protocol.AuthQrStatusResponse{Status: s.Status}
if s.Status == "confirmed" {
resp.Token = s.Token
resp.User = &protocol.UserInfo{UserID: s.UserID, NicknameMasked: s.Nick}
// 一次性:下发后即删,防复用
h.RDB.Del(c, store.KeyAuthQr(c.Param("state")))
}
c.JSON(http.StatusOK, resp)
}
// QrCallback GET /v1/auth/wechat/callback?code=&state= 🔓
// 手机微信内授权后回调:code 换身份 → 建号 → 标记 state confirmed。
func (h *Handlers) QrCallback(c *gin.Context) {
code, state := c.Query("code"), c.Query("state")
key := store.KeyAuthQr(state)
if n, _ := h.RDB.Exists(c, key).Result(); n == 0 {
c.String(http.StatusBadRequest, "二维码已过期,请回到 dudu 重新获取")
return
}
info, err := h.Wechat.ExchangeCode(c, code, "web")
if err != nil {
c.String(http.StatusBadRequest, "微信授权失败,请重试")
return
}
user, err := FindOrCreateUser(h.DB, info, "web")
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.String(http.StatusInternalServerError, "服务繁忙,请重试")
return
}
b, _ := json.Marshal(qrState{
Status: "confirmed", Token: token, UserID: user.ID, Nick: MaskNickname(user.Nickname),
})
_ = h.RDB.Set(c, key, b, qrTTL).Err()
c.String(http.StatusOK, "登录成功,回到 dudu 继续")
}
// MobileLogin POST /v1/auth/wechat 🔓(移动端 OpenSDK code
func (h *Handlers) MobileLogin(c *gin.Context) {
var req protocol.AuthWechatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
info, err := h.Wechat.ExchangeCode(c, req.Code, "mobile")
if err != nil {
c.JSON(http.StatusBadRequest, protocol.NewAPIError(protocol.ErrBadRequest))
return
}
user, err := FindOrCreateUser(h.DB, info, "mobile")
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
token, err := h.JWT.Sign(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, protocol.NewAPIError(protocol.ErrInternal))
return
}
c.JSON(http.StatusOK, protocol.AuthTokenResponse{
Token: token,
User: protocol.UserInfo{UserID: user.ID, NicknameMasked: MaskNickname(user.Nickname)},
})
}
// Logout POST /v1/auth/logout(需登录)
// 拉黑到 token 真实自然过期时间(中间件解析后存于 CtxExpires),确保即便
// JWT_TTL_HOURS 配得很大,注销 token 也不会在固定窗口后复活(17B)。
func (h *Handlers) Logout(c *gin.Context) {
jti := c.GetString(CtxJTI)
exp, _ := c.Get(CtxExpires)
expiresAt, _ := exp.(time.Time)
if expiresAt.IsZero() {
// 兜底:claims 未带 exp(理论上不会发生),按当前配置 TTL 估一个上界。
expiresAt = time.Now().Add(time.Duration(h.JWT.ttl))
}
_ = h.JWT.Revoke(c, jti, expiresAt)
c.Status(http.StatusNoContent)
}