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)