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
+5 -1
View File
@@ -32,7 +32,11 @@ MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn
# ── 登录鉴权 ──
JWT_SECRET=改成随机长字符串
JWT_EXPIRE_HOURS=168
# access token 有效期(小时)。短一点更安全,过期由前端拿 refresh token 静默续期
JWT_EXPIRE_HOURS=2
# 刷新令牌有效期:小程序 30 天、后台 7 天。到期才需要重新登录
JWT_REFRESH_EXPIRE_HOURS=720
JWT_ADMIN_REFRESH_EXPIRE_HOURS=168
# 生产必须 false,否则任何人都能用昵称直接登录
AUTH_DEV_LOGIN=false
+4 -1
View File
@@ -38,7 +38,10 @@ services:
PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?}
PETS_JWT_SECRET: ${JWT_SECRET:?}
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-168}
# access token 2 小时;过期由前端拿 refresh token 静默续期
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2}
PETS_JWT_REFRESH_EXPIRE_HOURS: ${JWT_REFRESH_EXPIRE_HOURS:-720}
PETS_JWT_ADMIN_REFRESH_EXPIRE_HOURS: ${JWT_ADMIN_REFRESH_EXPIRE_HOURS:-168}
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
+15
View File
@@ -47,3 +47,18 @@ make admin-dev # vite dev server :5173,已代理 /api → :8080
## 登录鉴权
默认 `auth.dev_login: true``POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。
接真微信:在 `configs/config.yaml``wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。
### access token + refresh token
登录接口返回 `token`(access) 与 `refresh_token` 两个令牌:
- **access token**JWT,默认 **2 小时**`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。
- **refresh token**:随机串,落库 `sundynix_refresh_tokens`**只存 sha256**)。小程序 30 天、后台 7 天。
- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。
- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。
安全约定:
- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。
- 主动退出、被连坐吊销的令牌不享受宽限期。
- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。
前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。
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>
+8 -2
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"`
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{})
}
+6 -1
View File
@@ -32,11 +32,16 @@ type Manager struct {
func NewManager(secret string, expireHours int) *Manager {
if expireHours <= 0 {
expireHours = 168
expireHours = 2
}
return &Manager{secret: []byte(secret), expireHours: expireHours}
}
// ExpireSeconds access token 剩余有效秒数,返给前端用于提前续期
func (m *Manager) ExpireSeconds() int {
return m.expireHours * 3600
}
// Generate 签发令牌
func (m *Manager) Generate(id string, kind Kind, name, role string) (string, error) {
now := time.Now()
+3 -1
View File
@@ -1,6 +1,6 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api'
import { api, clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -20,6 +20,8 @@ const nav = [
export default function Layout() {
const navigate = useNavigate()
function logout() {
// 服务端作废刷新令牌,成不成都要本地清掉
api.logout().catch(() => {})
clearToken()
navigate('/login')
}
+57 -5
View File
@@ -1,15 +1,21 @@
import axios from 'axios'
const TOKEN_KEY = 'pets_admin_token'
const REFRESH_KEY = 'pets_admin_refresh'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function setToken(t: string) {
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY) || ''
}
export function setToken(t: string, refresh?: string) {
localStorage.setItem(TOKEN_KEY, t)
if (refresh) localStorage.setItem(REFRESH_KEY, refresh)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
}
const http = axios.create({ baseURL: '/api' })
@@ -20,15 +26,57 @@ http.interceptors.request.use((cfg) => {
return cfg
})
// access token 只有 2 小时,过期后拿 refresh token 换新的。
// 用单飞(single-flight)保证并发请求只触发一次刷新,否则刷新令牌会被并发轮换掉。
let refreshing: Promise<string> | null = null
function refreshAccessToken(): Promise<string> {
if (!refreshing) {
const rt = getRefreshToken()
refreshing = (
rt
? axios.post('/api/admin/refresh', { refresh_token: rt }).then((r) => {
const body = r.data
if (!body || body.code !== 0) throw new Error(body?.message || '续期失败')
setToken(body.data.token, body.data.refresh_token)
return body.data.token as string
})
: Promise.reject(new Error('没有刷新令牌'))
).finally(() => {
refreshing = null
})
}
return refreshing
}
function logout() {
clearToken()
if (location.pathname !== '/admin/login') location.href = '/admin/login'
}
// 这几个接口本身就可能返回 40100(密码错、刷新令牌失效),不能再触发刷新,否则会绕圈
const NO_REFRESH = ['/admin/login', '/admin/refresh', '/admin/logout']
// 统一响应 {code,message,data}:成功返回 data,失败抛错
http.interceptors.response.use(
(resp) => {
async (resp) => {
const body = resp.data
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) return body.data
if (body.code === 40100) {
clearToken()
if (location.pathname !== '/admin/login') location.href = '/admin/login'
const cfg = resp.config as typeof resp.config & { _retried?: boolean }
const skip = NO_REFRESH.some((p) => (cfg.url || '').startsWith(p))
if (!skip && !cfg._retried) {
cfg._retried = true
try {
const t = await refreshAccessToken()
cfg.headers.Authorization = `Bearer ${t}`
return await http.request(cfg)
} catch {
/* 续期失败,落到下面退出登录 */
}
}
if (!skip) logout()
}
return Promise.reject(new Error(body.message || '请求失败'))
}
@@ -46,7 +94,11 @@ export interface Page<T> {
export const api = {
login: (username: string, password: string) =>
http.post<any, { token: string; admin: any }>('/admin/login', { username, password }),
http.post<any, { token: string; refresh_token: string; expires_in: number; admin: any }>(
'/admin/login',
{ username, password },
),
logout: () => http.post('/admin/logout', { refresh_token: getRefreshToken() }),
me: () => http.get<any, any>('/admin/me'),
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
+22 -6
View File
@@ -7,19 +7,24 @@ import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function Login() {
const [username, setUsername] = useState('sundynix')
const [password, setPassword] = useState('sundynix')
// 不预填账号密码:预填等于把后台凭证写在页面上
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function submit(e: React.FormEvent) {
e.preventDefault()
if (!username.trim() || !password) {
setError('请输入账号和密码')
return
}
setLoading(true)
setError('')
try {
const res = await api.login(username, password)
setToken(res.token)
const res = await api.login(username.trim(), password)
setToken(res.token, res.refresh_token)
navigate('/')
} catch (err: any) {
setError(err.message || '登录失败')
@@ -39,11 +44,22 @@ export default function Login() {
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label></Label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} />
<Input
value={username}
autoComplete="username"
placeholder="请输入账号"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Input
type="password"
value={password}
autoComplete="current-password"
placeholder="请输入密码"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8080',
'/api': 'http://localhost:9090',
},
},
})
+18 -7
View File
@@ -1,4 +1,11 @@
const { request, uploadFile, setToken } = require('./request.js');
const { request, uploadFile, setToken, setRefreshToken } = require('./request.js');
// 登录成功后统一落盘 access + refresh 两个令牌
function saveTokens(data) {
setToken(data.token);
setRefreshToken(data.refresh_token);
return data;
}
// wx.login 取 code
function wxLogin() {
@@ -14,14 +21,18 @@ function wxLogin() {
async function login() {
try {
const code = await wxLogin();
const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code } });
setToken(data.token);
return data;
// noAuthRetry:登录接口失败时也会返回 40100,不能再触发「续期→重新登录」,否则会自己套自己
const data = await request({ url: '/api/auth/wechat', method: 'POST', data: { code }, noAuthRetry: true });
return saveTokens(data);
} catch (e) {
console.warn('[api] 微信登录失败,回退 Mock 登录:', e && e.message);
const data = await request({ url: '/api/auth/login', method: 'POST', data: { nickname: '开发用户' } });
setToken(data.token);
return data;
const data = await request({
url: '/api/auth/login',
method: 'POST',
data: { nickname: '开发用户' },
noAuthRetry: true,
});
return saveTokens(data);
}
}
+69 -10
View File
@@ -1,6 +1,7 @@
const { BASE_URL } = require('./config.js');
const TOKEN_KEY = 'pets_token';
const REFRESH_KEY = 'pets_refresh_token';
function getToken() {
return wx.getStorageSync(TOKEN_KEY) || '';
@@ -8,8 +9,15 @@ function getToken() {
function setToken(t) {
wx.setStorageSync(TOKEN_KEY, t);
}
function getRefreshToken() {
return wx.getStorageSync(REFRESH_KEY) || '';
}
function setRefreshToken(t) {
if (t) wx.setStorageSync(REFRESH_KEY, t);
}
function clearToken() {
wx.removeStorageSync(TOKEN_KEY);
wx.removeStorageSync(REFRESH_KEY);
}
// token 失效时的重新登录钩子,由 store 注册(放在这里避免 request ← api ← store 循环依赖)
@@ -20,10 +28,43 @@ function setReAuthHandler(fn) {
const TIMEOUT = 15000;
// access token 只有 2 小时,过期后先拿 refresh token 换新的(用户无感),
// 换不到(刷新令牌也过期/被吊销)才退回 wx.login 重新登录。
// 单飞:并发请求同时 401 时只发一次刷新,否则刷新令牌会被并发轮换掉。
let refreshing = null;
function refreshAccessToken() {
if (!refreshing) {
const rt = getRefreshToken();
refreshing = (rt
? request({ url: '/api/auth/refresh', method: 'POST', data: { refresh_token: rt }, noAuthRetry: true })
: Promise.reject(new Error('没有刷新令牌'))
).then((data) => {
setToken(data.token);
setRefreshToken(data.refresh_token);
return data.token;
});
const done = () => {
refreshing = null;
};
refreshing.then(done, done);
}
return refreshing;
}
// 恢复登录态:先续期,续不了就整套重新登录
function recoverAuth() {
return refreshAccessToken().catch(() => {
clearToken();
if (!reAuth) throw new Error('登录已过期');
return reAuth();
});
}
// 统一请求:注入 token,解析 {code,message,data},成功返回 data,失败 reject(Error)。
// token 失效(40100)时自动重新登录并重试一次,避免 JWT 过期后小程序静默白屏。
// token 失效(40100)时自动续期/重新登录并重试一次,避免 JWT 过期后小程序静默白屏。
function request(options, _retried) {
const { url, method = 'GET', data, header = {} } = options;
const { url, method = 'GET', data, header = {}, noAuthRetry } = options;
return new Promise((resolve, reject) => {
const token = getToken();
wx.request({
@@ -41,13 +82,17 @@ function request(options, _retried) {
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) return resolve(body.data);
if (body.code === 40100) {
// 只重试一次,防止刷新/登录接口本身出问题时无限循环
if (_retried || noAuthRetry) {
clearToken();
// 只重试一次,防止登录接口本身出问题时无限循环
if (!_retried && reAuth) {
return reAuth()
.then(() => request(options, true).then(resolve, reject))
.catch(() => reject(new Error(body.message || '登录已过期,请重进小程序')));
return reject(new Error(body.message || '登录已过期,请重进小程序'));
}
return recoverAuth()
.then(() => request(options, true).then(resolve, reject))
.catch(() => {
clearToken();
reject(new Error(body.message || '登录已过期,请重进小程序'));
});
}
return reject(new Error(body.message || '请求失败'));
}
@@ -61,8 +106,8 @@ function request(options, _retried) {
});
}
// 文件上传(multipart
function uploadFile(filePath) {
// 文件上传(multiparttoken 过期同样先续期再重试一次
function uploadFile(filePath, _retried) {
return new Promise((resolve, reject) => {
const token = getToken();
wx.uploadFile({
@@ -75,6 +120,11 @@ function uploadFile(filePath) {
try {
const body = JSON.parse(res.data);
if (body.code === 0) return resolve(body.data);
if (body.code === 40100 && !_retried) {
return recoverAuth()
.then(() => uploadFile(filePath, true).then(resolve, reject))
.catch(() => reject(new Error('登录已过期,请重进小程序')));
}
reject(new Error(body.message || '上传失败'));
} catch (e) {
reject(new Error('上传响应解析失败'));
@@ -87,4 +137,13 @@ function uploadFile(filePath) {
});
}
module.exports = { request, uploadFile, getToken, setToken, clearToken, setReAuthHandler };
module.exports = {
request,
uploadFile,
getToken,
setToken,
getRefreshToken,
setRefreshToken,
clearToken,
setReAuthHandler,
};