feat(auth): access token 缩到 2 小时 + refresh token 机制

后台登录框原来预填了 sundynix/sundynix,等于把凭证写在页面上,去掉。

令牌改为 access(2h) + refresh 两段式:
- 新表 sundynix_refresh_tokens,只存 sha256,明文只在签发时返回一次
- 有效期:小程序 30 天、后台 7 天
- 新接口 /api/auth/refresh|logout、/api/admin/refresh|logout

安全约定:
- 每次续期都轮换刷新令牌,旧的立即作废
- 作废后 60 秒内再到达算并发重试放行,超过则判定泄露、吊销该账号全部会话
- 主动退出与被连坐吊销的令牌不吃宽限期,否则「吊销全部」形同虚设
- 禁用用户时一并吊销刷新令牌,最多 2 小时彻底失去访问

两端请求层都做了单飞续期:并发请求同时 401 只发一次 refresh,
否则刷新令牌会被并发轮换掉互相打架。小程序续期失败回退 wx.login,
登录/续期请求标 noAuthRetry,避免登录失败(同样返回 40100)触发自我套娃。

顺带修掉 vite 代理仍指向 8080 的遗留(端口早已改 9090)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 09:38:19 +08:00
parent 7093e6517d
commit 9adafb1354
21 changed files with 569 additions and 104 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-BmWfHKYa.js"></script>
<script type="module" crossorigin src="/admin/assets/index-1jDx3IlD.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
</head>
<body>
+9 -3
View File
@@ -64,9 +64,13 @@ type MinIOConfig struct {
PublicBaseURL string `mapstructure:"public_base_url"`
}
// JWTConfig access token 短、refresh token 长:
// access 过期由前端拿 refresh 静默续期,refresh 每次使用都轮换。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"` // access token 有效期
RefreshExpireHours int `mapstructure:"refresh_expire_hours"` // 小程序用户刷新令牌有效期
AdminRefreshExpireHours int `mapstructure:"admin_refresh_expire_hours"` // 后台刷新令牌有效期(更短)
}
type WeChatConfig struct {
@@ -105,7 +109,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("minio.public_base_url", "")
v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 168)
v.SetDefault("jwt.expire_hours", 2)
v.SetDefault("jwt.refresh_expire_hours", 720) // 30 天
v.SetDefault("jwt.admin_refresh_expire_hours", 168)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
+56 -2
View File
@@ -27,12 +27,66 @@ func (h *Handler) AdminLogin(c *gin.Context) {
response.Fail(c, 40100, "账号或密码错误")
return
}
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "admin": admin})
response.OK(c, data)
}
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"admin": admin,
}, nil
}
// AdminRefresh POST /api/admin/refresh
func (h *Handler) AdminRefresh(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
admin, err := h.svc.GetAdmin(adminID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if admin.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
func (h *Handler) AdminLogout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
response.OK(c, gin.H{"ok": true})
}
// AdminMe GET /api/admin/me
+65 -4
View File
@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
@@ -12,6 +13,26 @@ type loginReq struct {
Nickname string `json:"nickname"`
}
// userTokens 签发小程序用户的「access + refresh」令牌对。
// access token 只有 2 小时,前端过期后拿 refresh_token 静默续期。
func (h *Handler) userTokens(user *model.User) (gin.H, error) {
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindUser), user.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"user": user,
}, nil
}
// Login 开发态 Mock 登录
func (h *Handler) Login(c *gin.Context) {
if !h.cfg.Auth.DevLogin {
@@ -26,12 +47,12 @@ func (h *Handler) Login(c *gin.Context) {
response.FailErr(c, err)
return
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
response.OK(c, data)
}
type wechatLoginReq struct {
@@ -70,12 +91,52 @@ func (h *Handler) WechatLogin(c *gin.Context) {
}
}
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
response.OK(c, data)
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
// RefreshToken POST /api/auth/refresh
// 用刷新令牌换新的 access token;刷新令牌本身也会轮换,旧的立即失效。
func (h *Handler) RefreshToken(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
userID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
user, err := h.svc.GetUser(userID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if user.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// Logout POST /api/auth/logout 作废当前刷新令牌
func (h *Handler) Logout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
response.OK(c, gin.H{"ok": true})
}
// Profile 当前用户资料
+1
View File
@@ -47,5 +47,6 @@ func AllModels() []any {
&File{},
&Feedback{},
&Follow{},
&RefreshToken{},
}
}
+19
View File
@@ -0,0 +1,19 @@
package model
import "time"
// RefreshToken 刷新令牌。
// access token(JWT) 只有 2 小时,过期后用它换新的,用户不必重新登录;
// 库里只存 sha256,即使数据库泄露也换不出可用令牌。
// 每次刷新都会轮换(旧的立即作废),旧令牌被再次使用视为泄露。
type RefreshToken struct {
Base
TokenHash string `gorm:"size:64;uniqueIndex" json:"-"`
Kind string `gorm:"size:16;index" json:"kind"` // user | admin
SubjectID string `gorm:"size:24;index" json:"subject_id"` // 用户 ID 或管理员 ID
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
// 作废原因:rotate=正常轮换(有宽限期)/ logout=主动退出 / reuse=检测到泄露被连坐吊销。
// 只有 rotate 允许宽限期内再用一次,否则「吊销全部会话」会被宽限期架空。
RevokedReason string `gorm:"size:16" json:"revoked_reason"`
}
+5
View File
@@ -35,6 +35,9 @@ func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
auth := api.Group("/auth")
auth.POST("/login", h.Login)
auth.POST("/wechat", h.WechatLogin)
// 刷新/退出走刷新令牌,不需要(也不可能带)有效的 access token
auth.POST("/refresh", h.RefreshToken)
auth.POST("/logout", h.Logout)
}
// 小程序用户接口(需 user JWT)
@@ -113,6 +116,8 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
// 后台接口(登录开放,其余需 admin JWT)
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
api.POST("/admin/login", h.AdminLogin)
api.POST("/admin/refresh", h.AdminRefresh)
api.POST("/admin/logout", h.AdminLogout)
g := api.Group("/admin")
g.Use(middleware.AuthAdmin(jm))
+8 -1
View File
@@ -72,7 +72,14 @@ func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]mode
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id string, disabled bool) error {
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
return err
}
if disabled {
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
s.RevokeAllRefreshTokens("user", id)
}
return nil
}
// ListPetsAdmin 宠物分页
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"log"
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
)
// ErrRefreshInvalid 刷新令牌不存在 / 已过期 / 已被用过
var ErrRefreshInvalid = errors.New("登录已过期,请重新登录")
// 轮换宽限期:同一个刷新令牌在被消费后的这段时间内再次到达,视为「并发请求 / 网络重试」
// 而不是泄露(多标签页、小程序并发请求都会撞上)。超过这个窗口才按泄露处理。
const refreshGrace = 60 * time.Second
// 作废原因
const (
revokeRotate = "rotate"
revokeLogout = "logout"
revokeReuse = "reuse"
)
func hashRefreshToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// refreshTTL 按主体类型取有效期:后台比小程序短
func (s *Service) refreshTTL(kind string) time.Duration {
h := s.cfg.JWT.RefreshExpireHours
if kind == string(appjwt.KindAdmin) {
h = s.cfg.JWT.AdminRefreshExpireHours
}
if h <= 0 {
h = 168
}
return time.Duration(h) * time.Hour
}
// IssueRefreshToken 签发刷新令牌,返回明文(只在这一次返回,库里只存哈希)
func (s *Service) IssueRefreshToken(kind, subjectID string) (string, time.Time, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", time.Time{}, err
}
raw := base64.RawURLEncoding.EncodeToString(buf)
expires := time.Now().Add(s.refreshTTL(kind))
rt := model.RefreshToken{
TokenHash: hashRefreshToken(raw),
Kind: kind,
SubjectID: subjectID,
ExpiresAt: expires,
}
if err := s.db.Create(&rt).Error; err != nil {
return "", time.Time{}, err
}
s.gcRefreshTokens()
return raw, expires, nil
}
// ConsumeRefreshToken 校验并轮换刷新令牌,返回其所属主体 ID。
// 调用方随后应签发新的 access token + 新的刷新令牌。
func (s *Service) ConsumeRefreshToken(raw, kind string) (string, error) {
if raw == "" {
return "", ErrRefreshInvalid
}
var rt model.RefreshToken
err := s.db.Where("token_hash = ? AND kind = ?", hashRefreshToken(raw), kind).First(&rt).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", ErrRefreshInvalid
}
if err != nil {
return "", err
}
if time.Now().After(rt.ExpiresAt) {
return "", ErrRefreshInvalid
}
if rt.RevokedAt != nil {
// 正常轮换掉的令牌在宽限期内又到达:多半是并发刷新或网络重试,放行但不重复作废。
// 主动退出 / 被连坐吊销的令牌不给这个待遇,否则吊销等于没吊销。
if rt.RevokedReason == revokeRotate && time.Since(*rt.RevokedAt) <= refreshGrace {
return rt.SubjectID, nil
}
if rt.RevokedReason == revokeRotate {
// 早就轮换掉的令牌又被拿来用 —— 按泄露处理,把该主体所有令牌一起吊销
log.Printf("auth: 检测到已作废刷新令牌被重复使用 kind=%s subject=%s,吊销其全部会话", rt.Kind, rt.SubjectID)
s.revokeAll(rt.Kind, rt.SubjectID, revokeReuse)
}
return "", ErrRefreshInvalid
}
// 条件更新:只有仍未作废的那一行能被命中,天然挡住并发重复消费
res := s.db.Model(&model.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", rt.ID).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": revokeRotate})
if res.Error != nil {
return "", res.Error
}
if res.RowsAffected == 0 {
return "", ErrRefreshInvalid
}
return rt.SubjectID, nil
}
// RevokeRefreshToken 退出登录时作废单个令牌(找不到也当成功,不给探测口子)
func (s *Service) RevokeRefreshToken(raw, kind string) {
if raw == "" {
return
}
s.db.Model(&model.RefreshToken{}).
Where("token_hash = ? AND kind = ? AND revoked_at IS NULL", hashRefreshToken(raw), kind).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": revokeLogout})
}
// RevokeAllRefreshTokens 吊销某主体的全部会话(改密码、禁用账号时用)
func (s *Service) RevokeAllRefreshTokens(kind, subjectID string) {
s.revokeAll(kind, subjectID, revokeLogout)
}
func (s *Service) revokeAll(kind, subjectID, reason string) {
s.db.Model(&model.RefreshToken{}).
Where("kind = ? AND subject_id = ? AND revoked_at IS NULL", kind, subjectID).
Updates(map[string]any{"revoked_at": time.Now(), "revoked_reason": reason})
if reason == revokeReuse {
// 连坐时把「还在宽限期内」的轮换记录也一并改掉,否则它们还能再换出一副令牌
s.db.Model(&model.RefreshToken{}).
Where("kind = ? AND subject_id = ? AND revoked_reason = ?", kind, subjectID, revokeRotate).
Update("revoked_reason", revokeReuse)
}
}
// gcRefreshTokens 清掉过期很久的记录,避免表无限增长(顺带在签发时做,不另起定时任务)
func (s *Service) gcRefreshTokens() {
s.db.Where("expires_at < ?", time.Now().Add(-7*24*time.Hour)).Delete(&model.RefreshToken{})
}