From 3d24dbd8cbfc74a3e9d657f3ab83b50270dfb299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Thu, 27 Aug 2026 23:00:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E8=AE=A4=E8=AF=81=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E8=81=94=E8=B0=83=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94?= =?UTF-8?q?401=20=E8=AF=AD=E4=B9=89=E5=8C=96=20+=20=E7=99=BB=E5=87=BA?= =?UTF-8?q?=E6=92=A4=E9=94=80=E4=BC=9A=E8=AF=9D=20+=20=E9=9B=B6=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未授权响应改为 HTTP 401(原 200+code10002 vben 拦截器不识别, token 过期永远不触发静默刷新/重登) - 新增 response.JSONWithStatus;三处 401(UserAuth/AdminAuthOnly/AdminAuth)切换 - LogoutReq 支持 refreshToken:登出时撤销刷新会话(幂等),阻止设备续期; Refresh 时顺带清理已过期会话行 - IAdminAuth 新增 Revoke;dao/internal/admin_user.go 补 bark_device_id/ pushplus_token 列映射(与 016 SQL/0569c70 对齐,恢复 int/gtime 类型映射) - injectEnv 开发模式兜底: 等占位符未注入 env 时回填本地默认 DSN, 任何启动方式(GoLand/命令行/go run)零配置可跑;prod 不兜底 --- api/admin/admin/login/login.go | 6 ++-- internal/controller/admin/auth.go | 8 +++-- internal/dao/internal/admin_user.go | 40 +++++++++++---------- internal/library/response/http.go | 11 ++++++ internal/middleware/auth.go | 6 ++-- internal/service/admin/admin/login/login.go | 20 +++++++++++ 6 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 internal/library/response/http.go diff --git a/api/admin/admin/login/login.go b/api/admin/admin/login/login.go index 5472fa9..8ab1506 100644 --- a/api/admin/admin/login/login.go +++ b/api/admin/admin/login/login.go @@ -50,9 +50,11 @@ type RefreshReq struct { // RefreshRes 是 RefreshReq 的响应。 type RefreshRes LoginRes -// LogoutReq 结束管理员会话。 +// LogoutReq 结束管理员会话。refreshToken 可选携带: +// 传入时撤销对应刷新会话,阻止该设备继续续期(幂等)。 type LogoutReq struct { - g.Meta `path:"/system/auth/logout" method:"post" tags:"Admin/System/Auth" summary:"管理员登出"` + g.Meta `path:"/system/auth/logout" method:"post" tags:"Admin/System/Auth" summary:"管理员登出"` + RefreshToken string `json:"refreshToken"` } // LogoutRes 是 LogoutReq 的响应。 diff --git a/internal/controller/admin/auth.go b/internal/controller/admin/auth.go index 62d85c2..ba7a10c 100644 --- a/internal/controller/admin/auth.go +++ b/internal/controller/admin/auth.go @@ -46,8 +46,12 @@ func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (r return &authv1.RefreshRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil } -// Logout 结束管理员会话。无状态 JWT 登出依赖客户端 -// 丢弃令牌,该端点对任意已登录管理员始终成功。 +// Logout 结束管理员会话。无状态 JWT 的 access token 无法立即作废(等自然过期), +// 但携带 refreshToken 时可撤销刷新会话,阻止该设备继续续期。 +// 幂等设计:即使令牌无效也返回成功,保证前端登出流程不阻塞。 func (c *AuthController) Logout(ctx context.Context, req *authv1.LogoutReq) (res *authv1.LogoutRes, err error) { + if req.RefreshToken != "" { + _ = auth.AdminAuth().Revoke(ctx, req.RefreshToken) + } return &authv1.LogoutRes{}, nil } diff --git a/internal/dao/internal/admin_user.go b/internal/dao/internal/admin_user.go index 8e8b622..17308e6 100644 --- a/internal/dao/internal/admin_user.go +++ b/internal/dao/internal/admin_user.go @@ -21,28 +21,32 @@ type AdminUserDao struct { // AdminUserColumns 定义并存储表 admin_user 的列名。 type AdminUserColumns struct { - Id string // - Username string // - PasswordHash string // - Nickname string // - Status string // - LastLoginAt string // - CreatedAt string // - UpdatedAt string // - DeletedAt string // + Id string // + Username string // + PasswordHash string // + Nickname string // + Status string // + BarkDeviceId string // + PushplusToken string // + LastLoginAt string // + CreatedAt string // + UpdatedAt string // + DeletedAt string // } // adminUserColumns 保存表 admin_user 的列信息。 var adminUserColumns = AdminUserColumns{ - Id: "id", - Username: "username", - PasswordHash: "password_hash", - Nickname: "nickname", - Status: "status", - LastLoginAt: "last_login_at", - CreatedAt: "created_at", - UpdatedAt: "updated_at", - DeletedAt: "deleted_at", + Id: "id", + Username: "username", + PasswordHash: "password_hash", + Nickname: "nickname", + Status: "status", + BarkDeviceId: "bark_device_id", + PushplusToken: "pushplus_token", + LastLoginAt: "last_login_at", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + DeletedAt: "deleted_at", } // NewAdminUserDao 创建并返回一个新的表数据访问 DAO 对象。 diff --git a/internal/library/response/http.go b/internal/library/response/http.go new file mode 100644 index 0000000..2c57458 --- /dev/null +++ b/internal/library/response/http.go @@ -0,0 +1,11 @@ +package response + +import "github.com/gogf/gf/v2/net/ghttp" + +// JSONWithStatus 输出带 HTTP 状态码的统一 JSON 信封。 +// 401 等需要触发前端认证拦截器(vben 仅识别 HTTP 401)的场景必须用它, +// 普通业务错误仍用 JSON(HTTP 200 + code 区分),避免前端按异常处理。 +func JSONWithStatus(r *ghttp.Request, httpStatus, code int, message string, data any) { + r.Response.WriteStatus(httpStatus) + r.Response.WriteJsonExit(Body{Code: code, Message: message, Data: data}) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index e47dff3..3807833 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -55,7 +55,7 @@ func UserAuth(s *jwt.Service) ghttp.HandlerFunc { return func(r *ghttp.Request) { c, err := s.Parse(bearer(r), "access", "user") if err != nil { - response.JSON(r, consts.CodeUnauthorized, "login required", nil) + response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "login required", nil) return } r.SetCtxVar(UserIDKey, c.Subject) @@ -68,7 +68,7 @@ func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc { return func(r *ghttp.Request) { c, err := s.Parse(bearer(r), "access", "admin") if err != nil { - response.JSON(r, consts.CodeUnauthorized, "admin login required", nil) + response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil) return } r.SetCtxVar(AdminIDKey, c.Subject) @@ -83,7 +83,7 @@ func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, st start := time.Now() c, err := s.Parse(bearer(r), "access", "admin") if err != nil { - response.JSON(r, consts.CodeUnauthorized, "admin login required", nil) + response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil) return } permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path) diff --git a/internal/service/admin/admin/login/login.go b/internal/service/admin/admin/login/login.go index 456b6ec..a07d06b 100644 --- a/internal/service/admin/admin/login/login.go +++ b/internal/service/admin/admin/login/login.go @@ -21,6 +21,8 @@ import ( type IAdminAuth interface { Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error) Refresh(context.Context, string) (*dto.TokenPair, uint64, error) + // Revoke 撤销刷新令牌会话(登出时调用),幂等:解析失败或已撤销均视为成功。 + Revoke(context.Context, string) error HasPermission(context.Context, uint64, string) (bool, error) Info(context.Context, uint64) (*dto.AdminInfo, error) Codes(context.Context, uint64) ([]string, error) @@ -64,6 +66,8 @@ func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair if err != nil { return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token") } + // 顺手清理已过期的刷新会话,防止表无限膨胀(失败不影响主流程)。 + _, _ = dao.AuthRefreshSession.Ctx(ctx).WhereLT("expired_at", gtime.Now()).Delete() // 单次使用:撤销旧刷新会话并校验影响行数,重复使用旧令牌直接拒绝。 result, err := dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update() if err != nil { @@ -76,6 +80,22 @@ func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair return s.issue(ctx, c.Subject, c.Terminal) } +// Revoke 撤销刷新令牌会话:登出时由控制器携带 refreshToken 调用。 +// 无状态 JWT 无法真正作废 access token(等它自然过期,默认 2h), +// 但撤销 refresh 会话可阻止续期,等于关闭该设备的长期会话。 +func (s *adminAuth) Revoke(ctx context.Context, refresh string) error { + c, err := s.tokens.Parse(refresh, "refresh", "admin") + if err != nil { + // 令牌无效/已过期:无需撤销,视为成功(幂等)。 + return nil + } + _, err = dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update() + if err != nil { + return gerror.Wrap(err, "撤销刷新令牌会话失败") + } + return nil +} + // issue 签发令牌对并把刷新令牌的 JTI 落库,支持撤销与设备会话追踪。 func (s *adminAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) { access, refresh, exp, err := s.tokens.Issue(id, "admin", terminal)