feat(auth): 验证码/注册/登录/JWT 鉴权模块 (tsk_2PFfyviECIXh)
实现 server/internal/auth 控制面鉴权底座,对应 doc/02 §4.1、doc/06 §3:
- POST /v1/auth/code:IP+邮箱双维度 Redis 滑窗限频(同邮箱 1/min、同 IP 10/h)
+ 一次性邮箱域黑名单(embed 词表)→ 6 位数字码写 auth:code:{email} TTL 10min
→ 异步发信(SMTP / 开发态 log mailer)。
- POST /v1/auth/register:验码(一次性,删 key;超次数烧码)→ 单事务建号
(uuid + dp_uuid 应用层生成、argon2id)+ 7 天 PRO 试用(source='trial')→ 签发 JWT。
- POST /v1/auth/login:argon2id 校验(失败恒定时、未知用户走 dummy hash);
失败计数限流 rl:login:{email} + 锁定;banned 拒绝。
- POST /v1/auth/refresh:RS256,access 15min + refresh 30d 落 Redis 白名单
jwt:refresh:{jti},旋转时删旧写新;JWT 头带 kid,验证端接受新旧公钥支持轮换。
- RequireAuth 中间件:解析 Bearer access,注入 user id(复用 codes.CtxKeyUserID
避免循环依赖)+ uuid + claims 到 context。
- 错误体统一走 internal/apierr 的 {code, message_zh, message_en},文案双语脱敏。
- 文件拆分:handler/service/password/token/ratelimit/emailcheck/store/mailer/
middleware/errors/keyloader;config 增加可选 JWT PEM 路径/kid 加载。
测试:password/token/ratelimit/emailcheck/service/handler/middleware 单测
(miniredis,含注册全流程、重复邮箱 409、验证码错误/过期/复用/烧码、限频 429
带 Retry-After、登录失败锁定、banned 拒绝、refresh 旋转后旧 token 失效),
go test -race 通过;集成测试 integration_test.go(testcontainers MySQL8+Redis,
register→login→refresh→受保护接口全链路,构建 tag integration)与 codes 模块同构。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// Handler adapts the auth Service to HTTP. Route shapes follow the OpenAPI
|
||||
// contract (POST /v1/auth/{code,register,login,refresh}).
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler builds a Handler.
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// RegisterRoutes mounts the four public auth endpoints onto r. The caller is
|
||||
// expected to mount this group WITHOUT the bearer-auth middleware.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/auth/code", h.SendCode)
|
||||
r.Post("/auth/register", h.Register)
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/refresh", h.Refresh)
|
||||
}
|
||||
|
||||
// ---- request/response bodies (mirror openapi.yaml) ----
|
||||
|
||||
type sendCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type tokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// SendCode handles POST /v1/auth/code.
|
||||
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req sendCodeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
retryAfter, apiErr := h.svc.SendCode(r.Context(), req.Email, clientIP(r))
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Register handles POST /v1/auth/register.
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// Login handles POST /v1/auth/login.
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// Refresh handles POST /v1/auth/refresh.
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, apiErr := h.svc.Refresh(r.Context(), req.RefreshToken)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decodeJSON decodes the request body, writing a 400 on malformed input.
|
||||
// Returns false if the caller should stop (error already written).
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst interface{}) bool {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeAPIErr(w, ErrInvalidRequest, 0)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeTokenPair writes a 200 TokenPair body.
|
||||
func writeTokenPair(w http.ResponseWriter, pair *TokenPair) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
ExpiresIn: pair.ExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// writeAPIErr maps an apierr.Error to the right HTTP status, attaching a
|
||||
// Retry-After header when provided.
|
||||
func writeAPIErr(w http.ResponseWriter, e *apierr.Error, retryAfter time.Duration) {
|
||||
status := statusFor(e)
|
||||
if retryAfter > 0 {
|
||||
secs := int(retryAfter.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
}
|
||||
apierr.WriteJSON(w, status, e)
|
||||
}
|
||||
|
||||
// statusFor maps auth error codes to HTTP status codes.
|
||||
func statusFor(e *apierr.Error) int {
|
||||
switch e.Code {
|
||||
case ErrInvalidRequest.Code, ErrEmailDisposable.Code, ErrCodeInvalid.Code:
|
||||
return http.StatusBadRequest
|
||||
case ErrEmailExists.Code:
|
||||
return http.StatusConflict
|
||||
case ErrRateLimited.Code, ErrAccountLocked.Code:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
|
||||
return http.StatusUnauthorized
|
||||
case ErrAccountBanned.Code:
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extracts the best-effort client IP, honouring X-Forwarded-For and
|
||||
// X-Real-IP set by the edge proxy. Used only as a rate-limit key — never logged.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// First entry is the original client.
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user