feat(auth): 认证链路联调修复——401 语义化 + 登出撤销会话 + 零配置兜底

- 未授权响应改为 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 不兜底
This commit is contained in:
夏犀麟 2026-08-27 23:00:53 +08:00
parent e868c1ef9c
commit 3d24dbd8cb
6 changed files with 66 additions and 25 deletions

View File

@ -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 的响应。

View File

@ -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
}

View File

@ -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 对象。

View File

@ -0,0 +1,11 @@
package response
import "github.com/gogf/gf/v2/net/ghttp"
// JSONWithStatus 输出带 HTTP 状态码的统一 JSON 信封。
// 401 等需要触发前端认证拦截器vben 仅识别 HTTP 401的场景必须用它
// 普通业务错误仍用 JSONHTTP 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})
}

View File

@ -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)

View File

@ -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)