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:
parent
e868c1ef9c
commit
3d24dbd8cb
@ -50,9 +50,11 @@ type RefreshReq struct {
|
|||||||
// RefreshRes 是 RefreshReq 的响应。
|
// RefreshRes 是 RefreshReq 的响应。
|
||||||
type RefreshRes LoginRes
|
type RefreshRes LoginRes
|
||||||
|
|
||||||
// LogoutReq 结束管理员会话。
|
// LogoutReq 结束管理员会话。refreshToken 可选携带:
|
||||||
|
// 传入时撤销对应刷新会话,阻止该设备继续续期(幂等)。
|
||||||
type LogoutReq struct {
|
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 的响应。
|
// LogoutRes 是 LogoutReq 的响应。
|
||||||
|
|||||||
@ -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
|
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) {
|
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
|
return &authv1.LogoutRes{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,8 @@ type AdminUserColumns struct {
|
|||||||
PasswordHash string //
|
PasswordHash string //
|
||||||
Nickname string //
|
Nickname string //
|
||||||
Status string //
|
Status string //
|
||||||
|
BarkDeviceId string //
|
||||||
|
PushplusToken string //
|
||||||
LastLoginAt string //
|
LastLoginAt string //
|
||||||
CreatedAt string //
|
CreatedAt string //
|
||||||
UpdatedAt string //
|
UpdatedAt string //
|
||||||
@ -39,6 +41,8 @@ var adminUserColumns = AdminUserColumns{
|
|||||||
PasswordHash: "password_hash",
|
PasswordHash: "password_hash",
|
||||||
Nickname: "nickname",
|
Nickname: "nickname",
|
||||||
Status: "status",
|
Status: "status",
|
||||||
|
BarkDeviceId: "bark_device_id",
|
||||||
|
PushplusToken: "pushplus_token",
|
||||||
LastLoginAt: "last_login_at",
|
LastLoginAt: "last_login_at",
|
||||||
CreatedAt: "created_at",
|
CreatedAt: "created_at",
|
||||||
UpdatedAt: "updated_at",
|
UpdatedAt: "updated_at",
|
||||||
|
|||||||
11
internal/library/response/http.go
Normal file
11
internal/library/response/http.go
Normal file
@ -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})
|
||||||
|
}
|
||||||
@ -55,7 +55,7 @@ func UserAuth(s *jwt.Service) ghttp.HandlerFunc {
|
|||||||
return func(r *ghttp.Request) {
|
return func(r *ghttp.Request) {
|
||||||
c, err := s.Parse(bearer(r), "access", "user")
|
c, err := s.Parse(bearer(r), "access", "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.JSON(r, consts.CodeUnauthorized, "login required", nil)
|
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "login required", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.SetCtxVar(UserIDKey, c.Subject)
|
r.SetCtxVar(UserIDKey, c.Subject)
|
||||||
@ -68,7 +68,7 @@ func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc {
|
|||||||
return func(r *ghttp.Request) {
|
return func(r *ghttp.Request) {
|
||||||
c, err := s.Parse(bearer(r), "access", "admin")
|
c, err := s.Parse(bearer(r), "access", "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
|
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.SetCtxVar(AdminIDKey, c.Subject)
|
r.SetCtxVar(AdminIDKey, c.Subject)
|
||||||
@ -83,7 +83,7 @@ func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, st
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
c, err := s.Parse(bearer(r), "access", "admin")
|
c, err := s.Parse(bearer(r), "access", "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
|
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path)
|
permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path)
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import (
|
|||||||
type IAdminAuth interface {
|
type IAdminAuth interface {
|
||||||
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
|
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
|
||||||
Refresh(context.Context, string) (*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)
|
HasPermission(context.Context, uint64, string) (bool, error)
|
||||||
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
Info(context.Context, uint64) (*dto.AdminInfo, error)
|
||||||
Codes(context.Context, uint64) ([]string, 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 {
|
if err != nil {
|
||||||
return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token")
|
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()
|
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 {
|
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)
|
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 落库,支持撤销与设备会话追踪。
|
// issue 签发令牌对并把刷新令牌的 JTI 落库,支持撤销与设备会话追踪。
|
||||||
func (s *adminAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) {
|
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)
|
access, refresh, exp, err := s.tokens.Issue(id, "admin", terminal)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user