Files
jiu/backend/internal/handler/auth.go
T
wangjia e41085a878 feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用
会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库):
- refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话
- 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计)
- 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED
- 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine

授权实时 phase + 心跳回带:
- LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登
- /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁

首次使用自动试用 + code-review 修复:
- 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务)
- ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级
- trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态)
- 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:04 +08:00

139 lines
4.2 KiB
Go

package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type AuthHandler struct {
svc *service.AuthService
licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil
}
func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler {
return &AuthHandler{svc: svc, licSvc: licSvc}
}
// Login POST /api/v1/auth/login
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
ShopCode string `json:"shop_code" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dev := service.DeviceInfo{
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
Platform: req.Platform,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev)
if err != nil {
status := http.StatusUnauthorized
if errors.Is(err, service.ErrTooManyAttempts) {
status = http.StatusTooManyRequests
} else if errors.Is(err, service.ErrPlatformNotAllowed) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"expires_in": pair.ExpiresIn,
"shop_id": pair.ShopID,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"real_name": user.RealName,
"role": user.Role,
},
},
})
}
// Register POST /api/v1/public/register
func (h *AuthHandler) Register(c *gin.Context) {
var req service.RegisterInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := h.svc.Register(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, result)
}
// Refresh POST /api/v1/auth/refresh
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pair, err := h.svc.RefreshTokens(req.RefreshToken)
if err != nil {
// 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效"
if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, pair)
}
// Logout POST /api/v1/auth/logout —— 撤销当前会话
func (h *AuthHandler) Logout(c *gin.Context) {
if err := h.svc.Logout(middleware.GetSID(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, gin.H{"ok": true})
}
// Ping POST /api/v1/auth/ping —— 心跳:会话已撤销则中间件直接 401(触发登出),
// 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info
// (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。
func (h *AuthHandler) Ping(c *gin.Context) {
if h.licSvc == nil {
util.RespondSuccess(c, gin.H{"ok": true})
return
}
view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c))
if err != nil {
// 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。
util.RespondSuccess(c, gin.H{"ok": true})
return
}
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
}