refactor(auth): Migrate token delivery to HttpOnly Cookie with version-based revocation

- Replace JWT in response body with HttpOnly Cookie (vb_access/vb_refresh) to prevent XSS token theft
    - Add Redis-based token version management with ±1 tolerance for multi-tab concurrent refresh
    - Implement strict refresh token rotation: version must match exactly, increment on each refresh
    - Simplify JWT Claims to only carry UserID + Type + Version, remove user profile fields
    - Remove session-based token tracking and cache blacklist in favor of version increment revocation
    - Remove getAuthHeaders, wrapAxios, wrapFetch, isExpired from frontend VBase client
    - Remove client-side token/localStorage management, frontend now relies on Cookie auto-attach
    - Add CookiePath config option and change default access token expiry from 24h to 15min
    - Update Vigo app initialization to use functional options pattern
    - Add empty-body cookie read fallback in refresh endpoint
master
veypi 3 weeks ago
parent bd033892d5
commit 4c021e7e93

@ -7,15 +7,15 @@
package auth
import (
"net/http"
"time"
"github.com/veypi/vbase/auth"
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/cache"
"github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"github.com/veypi/vigo/contrib/requestmeta"
)
// LoginRequest 登录请求
@ -27,12 +27,12 @@ type LoginRequest struct {
Remember bool `json:"remember,omitempty" src:"json" desc:"记住登录"`
}
// AuthResponse 认证响应
// AuthResponse 认证响应token 仅通过 HttpOnly Cookie 下发body 只含用户信息)
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
User *UserInfo `json:"user"`
}
@ -114,32 +114,25 @@ func loginWithCode(x *vigo.X, req *LoginWithCodeRequest) (*AuthResponse, error)
}
// 生成token
return generateAuthResponseForUser(&user)
return generateAuthResponseForUser(x, &user)
}
// generateAuthResponseForUser 为用户生成认证响应
func generateAuthResponseForUser(user *models.User) (*AuthResponse, error) {
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
// generateAuthResponseForUser 为用户生成认证响应(登录/注册时调用,不递增版本)
func generateAuthResponseForUser(x *vigo.X, user *models.User) (*AuthResponse, error) {
version, err := auth.GetTokenVersion(user.ID)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
tokenPair, err := jwt.GenerateTokenPair(
user.ID,
user.Username,
user.Nickname,
user.Avatar,
emailStr,
)
tokenPair, err := jwt.GenerateTokenPair(user.ID, version)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
// 设置 HttpOnly Cookie浏览器自动携带body 仅返回用户信息
setTokenCookies(x, tokenPair)
return &AuthResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
TokenType: tokenPair.TokenType,
ExpiresIn: tokenPair.ExpiresIn,
User: &UserInfo{
ID: user.ID,
Username: user.Username,
@ -169,52 +162,12 @@ func login(x *vigo.X, req *LoginRequest) (*AuthResponse, error) {
return nil, vigo.ErrUnauthorized.WithString("invalid username or password")
}
// 生成token
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
}
tokenPair, err := jwt.GenerateTokenPair(
user.ID,
user.Username,
user.Nickname,
user.Avatar,
emailStr,
)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
// 保存session
session := &models.Session{
UserID: user.ID,
TokenID: getJTI(tokenPair.AccessToken),
Type: "access",
DeviceInfo: x.Request.UserAgent(),
IP: requestmeta.RemoteIP(x),
ExpiresAt: time.Now().Add(cfg.Global.JWT.AccessExpiry),
}
cfg.DB().Create(session)
// 更新最后登录时间
now := time.Now()
cfg.DB().Model(&user).Update("last_login_at", now)
return &AuthResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
TokenType: tokenPair.TokenType,
ExpiresIn: tokenPair.ExpiresIn,
User: &UserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Avatar: user.Avatar,
},
}, nil
// 生成 token + Set-Cookie
return generateAuthResponseForUser(x, &user)
}
// RefreshRequest 刷新请求
@ -222,10 +175,20 @@ type RefreshRequest struct {
RefreshToken string `json:"refresh_token" src:"json" desc:"刷新令牌"`
}
// refresh 刷新Token
// refresh 刷新TokenToken Rotation严格验证版本并轮换
func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
// 解析refresh token
claims, err := jwt.ParseToken(req.RefreshToken)
refreshToken := req.RefreshToken
// Body 为空时从 Cookie 读取
if refreshToken == "" {
if c, err := x.Request.Cookie(refreshCookieName); err == nil {
refreshToken = c.Value
}
}
if refreshToken == "" {
return nil, vigo.ErrTokenInvalid
}
claims, err := jwt.ParseToken(refreshToken)
if err != nil {
if err == jwt.ErrExpiredToken {
return nil, vigo.ErrTokenExpired
@ -247,39 +210,22 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
return nil, vigo.ErrForbidden.WithString("user is disabled")
}
// 生成新token
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
// 严格验证 refresh_token 版本并递增(版本不匹配=已泄露/已用)
newVersion, err := auth.ValidateRefreshAndRotate(claims.UserID, claims.Version)
if err != nil {
return nil, vigo.ErrTokenInvalid.WithString("refresh token is stale or already used")
}
tokenPair, err := jwt.GenerateTokenPair(
user.ID,
user.Username,
user.Nickname,
user.Avatar,
emailStr,
)
// 生成新 token pair新版本号
tokenPair, err := jwt.GenerateTokenPair(user.ID, newVersion)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
// 保存新session
session := &models.Session{
UserID: user.ID,
TokenID: getJTI(tokenPair.AccessToken),
Type: "access",
DeviceInfo: x.Request.UserAgent(),
IP: requestmeta.RemoteIP(x),
ExpiresAt: time.Now().Add(cfg.Global.JWT.AccessExpiry),
}
cfg.DB().Create(session)
// 更新 Cookiebody 仅返回用户信息
setTokenCookies(x, tokenPair)
return &AuthResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
TokenType: tokenPair.TokenType,
ExpiresIn: tokenPair.ExpiresIn,
User: &UserInfo{
ID: user.ID,
Username: user.Username,
@ -290,52 +236,68 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
}, nil
}
// logout 用户登出
// logout 用户登出(版本递增,全部 token 失效)
func logout(x *vigo.X) error {
tokenString := extractToken(x)
if tokenString == "" {
return nil
}
jti, err := jwt.GetJTI(tokenString)
if err != nil {
userID := cfg.Auth.UserID(x)
if userID == "" {
return nil
}
// 加入黑名单
expiration, _ := jwt.GetExpiration(tokenString)
if cache.IsEnabled() {
ttl := time.Until(expiration)
if ttl > 0 {
cache.BlacklistToken(jti, ttl)
}
// 递增 token 版本 → 已签发的所有 token 全部失效
if err := auth.RevokeAllTokens(userID); err != nil {
return vigo.ErrInternalServer.WithError(err)
}
// 标记session为撤销
cfg.DB().Model(&models.Session{}).Where("token_id = ?", jti).Updates(map[string]any{
"revoked": true,
"revoked_at": time.Now(),
})
// 清除 Cookie
clearTokenCookies(x)
return nil
}
// helper functions
// ========== Cookie 辅助函数 ==========
func getJTI(token string) string {
jti, _ := jwt.GetJTI(token)
return jti
}
const (
accessCookieName = "vb_access"
refreshCookieName = "vb_refresh"
)
func extractToken(x *vigo.X) string {
// 从Header获取
auth := x.Request.Header.Get("Authorization")
if auth != "" {
if len(auth) > 7 && auth[:7] == "Bearer " {
return auth[7:]
}
// setTokenCookies 设置 HttpOnly Cookie
func setTokenCookies(x *vigo.X, tokenPair *jwt.TokenPair) {
refreshTokenPath := Router.String() + "/refresh"
cp := cfg.Global.JWT.CookiePath
http.SetCookie(x.ResponseWriter(), &http.Cookie{
Name: accessCookieName,
Value: tokenPair.AccessToken,
Path: cp,
MaxAge: int(cfg.Global.JWT.AccessExpiry.Seconds()),
HttpOnly: true,
Secure: x.Request.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
http.SetCookie(x.ResponseWriter(), &http.Cookie{
Name: refreshCookieName,
Value: tokenPair.RefreshToken,
Path: refreshTokenPath,
MaxAge: int(cfg.Global.JWT.RefreshExpiry.Seconds()),
HttpOnly: true,
Secure: x.Request.TLS != nil,
SameSite: http.SameSiteStrictMode,
})
}
// 从Query获取
return x.Request.URL.Query().Get("access_token")
// clearTokenCookies 清除 Cookie登出时调用
func clearTokenCookies(x *vigo.X) {
cp := cfg.Global.JWT.CookiePath
refreshTokenPath := Router.String() + "/refresh"
http.SetCookie(x.ResponseWriter(), &http.Cookie{
Name: accessCookieName,
Value: "",
Path: cp,
MaxAge: -1,
HttpOnly: true,
})
http.SetCookie(x.ResponseWriter(), &http.Cookie{
Name: refreshCookieName,
Value: "",
Path: refreshTokenPath,
MaxAge: -1,
HttpOnly: true,
})
}

@ -15,7 +15,6 @@ import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"github.com/veypi/vigo/logv"
@ -173,33 +172,5 @@ func register(x *vigo.X, req *RegisterRequest) (*AuthResponse, error) {
}
// 生成token
emailStr := ""
if user.Email != nil {
emailStr = *user.Email
}
tokenPair, err := jwt.GenerateTokenPair(
user.ID,
user.Username,
user.Nickname,
user.Avatar,
emailStr,
)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
return &AuthResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
TokenType: tokenPair.TokenType,
ExpiresIn: tokenPair.ExpiresIn,
User: &UserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Avatar: user.Avatar,
},
}, nil
return generateAuthResponseForUser(x, user)
}

@ -19,7 +19,6 @@ import (
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/cache"
"github.com/veypi/vbase/libs/crypto"
"github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
"gorm.io/gorm"
@ -693,35 +692,7 @@ func loginByIdentity(x *vigo.X, identity *models.Identity) (*CallbackResponse, e
}
func generateAuthResponse(x *vigo.X, user *models.User) (*AuthResponse, error) {
tokenPair, err := jwt.GenerateTokenPair(
user.ID,
user.Username,
user.Nickname,
user.Avatar,
func() string {
if user.Email != nil {
return *user.Email
}
return ""
}(),
)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
return &AuthResponse{
AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
TokenType: tokenPair.TokenType,
ExpiresIn: tokenPair.ExpiresIn,
User: &UserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
Avatar: user.Avatar,
},
}, nil
return generateAuthResponseForUser(x, user)
}
func generateTempBindToken(provider string, userInfo *ThirdPartyUserInfo) (string, error) {

@ -10,8 +10,10 @@ import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/redis/go-redis/v9"
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
@ -127,7 +129,13 @@ func (a *vbaseProvider) UserID(x *vigo.X) string {
return ""
}
// 4. 设置到上下文中,供后续调用使用
// 验证 token 版本(允许 ±1 防止多 tab 并发刷新互相踢)
if !validateTokenVersion(claims.UserID, claims.Version) {
x.Set(ctxKeyTokenParsed, true)
return ""
}
// 5. 设置到上下文中,供后续调用使用
x.Set(CtxKeyUserID, claims.UserID)
x.Set(ctxKeyTokenParsed, true)
@ -500,17 +508,91 @@ func parsePermissionID(x *vigo.X, code string) (string, error) {
return code[:start] + val + code[end+1:], nil
}
// extractToken 辅助函数
// extractToken 从请求中提取 token优先级: Cookie > Authorization Header > Query
func extractToken(x *vigo.X) string {
// 1. Cookie (HttpOnly浏览器自动携带)
if c, err := x.Request.Cookie("vb_access"); err == nil && c.Value != "" {
return c.Value
}
// 2. Authorization Header
auth := x.Request.Header.Get("Authorization")
if auth != "" {
if len(auth) > 7 && strings.HasPrefix(auth, "Bearer ") {
if auth != "" && len(auth) > 7 && strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
}
// 3. Query 参数
return x.Request.URL.Query().Get("access_token")
}
// ========== Token 版本管理 ==========
func userTokenVersionKey(userID string) string {
return fmt.Sprintf("vb:user:%s:token_ver", userID)
}
// getTokenVersion 获取用户当前 token 版本号
func getTokenVersion(userID string) (int64, error) {
rds := cfg.Redis()
if rds == nil {
return 0, fmt.Errorf("redis not available")
}
val, err := rds.Get(context.Background(), userTokenVersionKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return 0, nil // 首次,版本为 0
}
if err != nil {
return 0, err
}
return strconv.ParseInt(val, 10, 64)
}
// incrTokenVersion 递增用户 token 版本号,返回新版本
func incrTokenVersion(userID string) (int64, error) {
rds := cfg.Redis()
if rds == nil {
return 0, fmt.Errorf("redis not available")
}
newVer, err := rds.Incr(context.Background(), userTokenVersionKey(userID)).Result()
if err != nil {
return 0, err
}
return newVer, nil
}
// validateTokenVersion 验证 token 版本access_token 允许 ±1多 tab 容差)
func validateTokenVersion(userID string, tokenVer int64) bool {
currentVer, err := getTokenVersion(userID)
if err != nil {
return false
}
// 允许当前版本或上一版本(防止多 tab 并发刷新导致误踢)
return tokenVer == currentVer || tokenVer == currentVer-1
}
// GetTokenVersion 获取用户当前 token 版本号(导出给 api/auth 使用)
func GetTokenVersion(userID string) (int64, error) {
return getTokenVersion(userID)
}
// RevokeAllTokens 撤销用户所有 token递增版本号
func RevokeAllTokens(userID string) error {
_, err := incrTokenVersion(userID)
return err
}
// ValidateRefreshAndRotate 严格验证 refresh_token 版本并轮换,返回新版本号
func ValidateRefreshAndRotate(userID string, tokenVer int64) (int64, error) {
currentVer, err := getTokenVersion(userID)
if err != nil {
return 0, err
}
if tokenVer != currentVer {
return 0, fmt.Errorf("refresh token version mismatch: expected %d, got %d", currentVer, tokenVer)
}
return incrTokenVersion(userID)
}
func validatePermission(code string, level int) error {
if code == "*" {
if level != LevelAdmin {

@ -39,6 +39,7 @@ type JWTConfig struct {
AccessExpiry time.Duration `json:"access_expiry" usage:"Access Token 有效期"`
RefreshExpiry time.Duration `json:"refresh_expiry" usage:"Refresh Token 有效期"`
Issuer string `json:"issuer" usage:"JWT 签发者"`
CookiePath string `json:"cookie_path" usage:"Cookie Path默认 / 全站,可限定为 /api/ 等"`
}
// InitAdminConfig 初始管理员配置
@ -61,9 +62,10 @@ var Global = &Options{
Key: "your-secret-key-change-in-production-min-32-characters",
JWT: JWTConfig{
Secret: "",
AccessExpiry: time.Hour * 24,
AccessExpiry: 15 * time.Minute,
RefreshExpiry: 30 * 24 * time.Hour,
Issuer: "vbase",
CookiePath: "/",
},
InitAdmin: InitAdminConfig{
Username: "admin",

@ -14,6 +14,6 @@ import (
)
func main() {
app := vigo.New("vbase", vbase.Router, cfg.Global, vbase.Init)
app := vigo.New("vbase", vbase.Router, cfg.Global, vigo.WithInit(vbase.Init))
panic(app.Run())
}

@ -22,16 +22,12 @@ var (
ErrTokenRevoked = errors.New("token revoked")
)
// Claims JWT声明
// Claims JWT声明(精简版,仅存鉴权必需字段)
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"uid"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Email string `json:"email"`
Type string `json:"type"` // access/refresh
Scope string `json:"scope,omitempty"`
Type string `json:"type"` // "access" / "refresh"
Version int64 `json:"ver"` // token 版本号,用于撤销
}
// TokenPair Token对
@ -43,13 +39,13 @@ type TokenPair struct {
}
// GenerateTokenPair 生成token对
func GenerateTokenPair(userID, username, nickname, avatar, email string) (*TokenPair, error) {
accessToken, err := GenerateAccessToken(userID, username, nickname, avatar, email)
func GenerateTokenPair(userID string, version int64) (*TokenPair, error) {
accessToken, err := GenerateAccessToken(userID, version)
if err != nil {
return nil, err
}
refreshToken, err := GenerateRefreshToken(userID)
refreshToken, err := GenerateRefreshToken(userID, version)
if err != nil {
return nil, err
}
@ -62,8 +58,8 @@ func GenerateTokenPair(userID, username, nickname, avatar, email string) (*Token
}, nil
}
// GenerateAccessToken 生成访问令牌
func GenerateAccessToken(userID, username, nickname, avatar, email string) (string, error) {
// GenerateAccessToken 生成访问令牌(轻量,仅存 UserID + Version
func GenerateAccessToken(userID string, version int64) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
@ -76,11 +72,8 @@ func GenerateAccessToken(userID, username, nickname, avatar, email string) (stri
ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Global.JWT.AccessExpiry)),
},
UserID: userID,
Username: username,
Nickname: nickname,
Avatar: avatar,
Email: email,
Type: "access",
Version: version,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@ -88,7 +81,7 @@ func GenerateAccessToken(userID, username, nickname, avatar, email string) (stri
}
// GenerateRefreshToken 生成刷新令牌
func GenerateRefreshToken(userID string) (string, error) {
func GenerateRefreshToken(userID string, version int64) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
@ -100,6 +93,7 @@ func GenerateRefreshToken(userID string) (string, error) {
},
UserID: userID,
Type: "refresh",
Version: version,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@ -137,18 +131,6 @@ func GetJTI(tokenString string) (string, error) {
return claims.ID, nil
}
// GetExpiration 获取过期时间
func GetExpiration(tokenString string) (time.Time, error) {
claims, err := ParseToken(tokenString)
if err != nil {
return time.Time{}, err
}
if claims.ExpiresAt == nil {
return time.Time{}, errors.New("no expiration")
}
return claims.ExpiresAt.Time, nil
}
// IsAccessToken 是否是访问令牌
func IsAccessToken(claims *Claims) bool {
return claims.Type == "access"
@ -158,3 +140,12 @@ func IsAccessToken(claims *Claims) bool {
func IsRefreshToken(claims *Claims) bool {
return claims.Type == "refresh"
}
// GetSecret 获取当前 JWT Secret空则回退 Key
func GetSecret() string {
s := cfg.Global.JWT.Secret
if s == "" {
s = string(cfg.Global.Key)
}
return s
}

@ -14,8 +14,6 @@ export default async ($mod) => {
$mod.users = {}
const vbase = new VBase('vb', $mod.scoped, null, $mod.users); // Relative path
$mod.$auth = vbase;
// Wrap Axios: add auth header
vbase.wrapAxios($mod.$axios);
}
$mod.$axios.interceptors.response.use(function(response) {
return response?.data

@ -735,17 +735,11 @@
code: signInForm.code
});
if (data && data.access_token) {
$mod.$auth.token = data.access_token;
if (data.refresh_token) $mod.$auth.refreshToken = data.refresh_token;
if (data.user) $mod.$auth.user = data.user;
if (data && data.user) {
$mod.$auth.user = data.user;
$message.success($t('auth.login_success'));
if (redirect === '/' || redirect.startsWith('http')) {
window.location.href = redirect;
} else {
window.location.href = redirect;
}
} else {
throw new Error('Login failed: no token received');
}
@ -810,15 +804,8 @@
password: signUpForm.password
});
if (data && data.access_token) {
// Auto login after register using vbase setters
$mod.$auth.token = data.access_token;
if (data.refresh_token) {
$mod.$auth.refreshToken = data.refresh_token;
}
if (data.user) {
if (data && data.user) {
$mod.$auth.user = data.user;
}
$message.success($t('auth.register_success'));
window.location.href = redirect;

@ -43,15 +43,6 @@ export default ({ $mod }) => ({
const vbase = $mod.$auth
if (isAuth) {
if (vbase.isExpired()) {
try {
await vbase.refresh();
} catch (e) {
vbase.logout('/login?redirect=' + encodeURIComponent(to.fullPath));
return false;
}
}
if (!vbase.user) {
try {
await vbase.fetchUser();

@ -1,6 +1,9 @@
/**
* VBase - Scoped RBAC 权限管理客户端
* 对应后端: github.com/veypi/vigo/contrib/auth.Auth 接口
*
* Token 基于 HttpOnly Cookie 管理JS 不可读防沙箱泄露
* 非浏览器客户端仍可通过 Authorization header 传递 token
*/
// 权限等级常量 (与后端一致)
@ -21,60 +24,53 @@ class VBase {
this.baseURL = baseURL;
this.scope = scope;
this.login_page = login_page || (baseURL + '/login')
this.tokenKey = `token`;
this.refreshTokenKey = `refresh_token`;
this.userKey = `vbase_user`;
this.login_page = login_page || (baseURL + '/login');
this.userKey = 'vbase_user';
this.users = users;
this._token = localStorage.getItem(this.tokenKey) || '';
this._refreshToken = localStorage.getItem(this.refreshTokenKey) || '';
this._user = JSON.parse(localStorage.getItem(this.userKey) || 'null');
this._user = null;
try {
const cached = JSON.parse(localStorage.getItem(this.userKey));
if (cached) {
this._user = cached;
this._cachePublicUser(cached);
}
} catch (e) {}
this._pendingUserIDs = new Set();
this._loadingUserIDs = new Set();
this._resolvedUserIDs = new Set();
this._pendingUserFlush = null;
this._cachePublicUser(this._user);
if (this._token) {
this.fetchUser()
}
}
// ========== Getters ==========
get token() { return this._token; }
get refreshToken() { return this._refreshToken; }
get user() { return this._user; }
// ========== Setters ==========
set token(val) {
this._token = val;
if (val) localStorage.setItem(this.tokenKey, val);
else localStorage.removeItem(this.tokenKey);
// 验证登录状态
this.fetchUser().catch(() => {});
}
set refreshToken(val) {
this._refreshToken = val;
if (val) localStorage.setItem(this.refreshTokenKey, val);
else localStorage.removeItem(this.refreshTokenKey);
}
// ========== Getters / Setters ==========
get user() { return this._user; }
set user(val) {
this._user = val;
if (val) localStorage.setItem(this.userKey, JSON.stringify(val));
else localStorage.removeItem(this.userKey);
if (val) {
localStorage.setItem(this.userKey, JSON.stringify(val));
} else {
localStorage.removeItem(this.userKey);
}
this._cachePublicUser(val);
}
// ========== API 请求 ==========
async request(method, path, data = null, headers = {}) {
const url = `${this.baseURL}${path}`;
const config = {
method,
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
...headers
}
...headers,
},
};
if (data) config.body = JSON.stringify(data);
@ -88,93 +84,66 @@ class VBase {
}
if (resData.code && resData.code !== 200) {
throw new Error(resData.message || 'API Error');
const error = new Error(resData.message || 'API Error');
Object.assign(error, resData);
throw error;
}
return resData.data || resData;
}
// ========== 认证相关 ==========
// ========== 认证 ==========
/** 用户名密码登录 */
async login(username, password) {
const data = await this.request('POST', '/api/auth/login', { username, password });
if (data.access_token) {
this.token = data.access_token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
this.user = data.user
this.fetchUser()
if (data.user) {
this.user = data.user;
await this.fetchUser();
return true;
}
return false;
}
/**
* OAuth Callback Handler
* @param {string} provider
* @param {string} code
* @param {string} state
* @returns {Promise<Object>} Response data
*/
/** OAuth 回调 */
async oauthCallback(provider, code, state) {
const data = await this.request('GET', `/api/auth/callback/${provider}?code=${code}&state=${state}`);
// If login success directly
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user;
this.fetchUser()
if (data.user) {
this.user = data.user;
await this.fetchUser();
}
return data;
}
/**
* Bind existing account
* @param {string} tempToken
* @param {string} username
* @param {string} password
* @returns {Promise<Object>}
*/
/** 绑定已有账号 */
async bindAccount(tempToken, username, password) {
const data = await this.request('POST', '/api/auth/bind', {
temp_token: tempToken,
username,
password
password,
});
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
this.fetchUser()
if (data.user) {
this.user = data.user;
await this.fetchUser();
}
return data;
}
/**
* Register and bind new account
* @param {string} tempToken
* @param {string} username
* @param {string} email
* @returns {Promise<Object>}
*/
/** 绑定并注册 */
async bindRegister(tempToken, username, email) {
const data = await this.request('POST', '/api/auth/bind-register', {
temp_token: tempToken,
username,
email
email,
});
if (data.access_token || data.token) {
this.token = data.access_token || data.token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
if (data.user) this.user = data.user;
this.fetchUser()
if (data.user) {
this.user = data.user;
await this.fetchUser();
}
return data;
}
/** 登出 */
async logout(redirect) {
try {
await this.request('POST', '/api/auth/logout');
@ -187,39 +156,30 @@ class VBase {
}
}
/** Token 刷新(后端自动处理,前端可主动调用) */
async refresh() {
if (!this.refreshToken) return false;
try {
const data = await this.request('POST', '/api/auth/refresh', { refresh_token: this.refreshToken });
if (data.access_token) {
this.token = data.access_token;
if (data.refresh_token) this.refreshToken = data.refresh_token;
await this.request('POST', '/api/auth/refresh', {});
return true;
}
return false;
} catch (e) {
console.warn(e)
return false
return false;
}
}
/** 获取当前用户信息及权限 */
async fetchUser() {
const user = await this.request('GET', '/api/auth/me').catch(e => {
this.clear()
});
try {
const user = await this.request('GET', '/api/auth/me');
this.user = user;
return user;
} catch (e) {
this.clear();
throw e;
}
getAuthHeaders() {
const headers = {};
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return headers;
}
/** 清除登录状态 */
clear() {
this.token = '';
this.refreshToken = '';
this.user = null;
for (const id of Object.keys(this.users)) {
delete this.users[id];
@ -230,6 +190,8 @@ class VBase {
this._pendingUserFlush = null;
}
// ========== 用户 ==========
User(id) {
if (!id) return {};
@ -247,28 +209,8 @@ class VBase {
return this.users[id];
}
isExpired(token) {
if (!token) token = this.token;
if (!token) return true;
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(window.atob(base64));
const now = Math.floor(Date.now() / 1000);
return payload.exp && payload.exp < now;
} catch (e) {
return true;
}
}
// ========== 权限检查 (与后端接口一致) ==========
// ========== 权限检查 ==========
/**
* 检查权限
* @param {string} code - 权限码 "resource:instance"
* @param {number} level - 需要的权限等级
* @returns {boolean}
*/
Perm(code, level = Level.Read) {
if (!this.user) return false;
if (!code) return true;
@ -277,133 +219,33 @@ class VBase {
return this._checkPermissionLevel(perms, code, level);
}
/**
* 检查创建权限 (level 1检查奇数层)
* @param {string} code - 权限码
* @returns {boolean}
*/
PermCreate(code) {
return this.Perm(code, Level.Create);
}
/**
* 检查读取权限 (level 2检查偶数层)
* @param {string} code - 权限码
* @returns {boolean}
*/
PermRead(code) {
return this.Perm(code, Level.Read);
}
/**
* 检查更新权限 (level 4检查偶数层)
* @param {string} code - 权限码
* @returns {boolean}
*/
PermWrite(code) {
return this.Perm(code, Level.Write);
}
/**
* 检查管理员权限 (level 7检查偶数层)
* @param {string} code - 权限码
* @returns {boolean}
*/
PermAdmin(code) {
return this.Perm(code, Level.Admin);
}
PermCreate(code) { return this.Perm(code, Level.Create); }
PermRead(code) { return this.Perm(code, Level.Read); }
PermWrite(code) { return this.Perm(code, Level.Write); }
PermAdmin(code) { return this.Perm(code, Level.Admin); }
// ========== 内部方法 ==========
/**
* 核心权限检查逻辑 (与后端 checkPermissionLevel 一致)
*/
_checkPermissionLevel(perms, targetPermID, requiredLevel) {
for (const p of perms) {
const permID = p.permission_id || p;
const permLevel = p.level !== undefined ? p.level : Level.Read;
// 1. 管理员特权 (Level 7 且是父级或同级)
if (permLevel === Level.Admin) {
if (permID === '*' || targetPermID.startsWith(permID + ':') || permID === targetPermID) {
return true;
}
}
// 2. 普通权限匹配
if (permLevel >= requiredLevel) {
if (permID === targetPermID) {
return true;
}
}
}
return false;
}
// ========== Axios 集成 ==========
wrapAxios(axiosInstance) {
// 请求拦截器
axiosInstance.interceptors.request.use(config => {
const headers = this.getAuthHeaders();
for (const key in headers) {
config.headers[key] = headers[key];
}
return config;
}, error => Promise.reject(error));
// 响应拦截器
axiosInstance.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await this.refresh();
const headers = this.getAuthHeaders();
originalRequest.headers['Authorization'] = headers['Authorization'];
return axiosInstance(originalRequest);
} catch (e) {
this.logout();
return Promise.reject(e);
}
}
return Promise.reject(error);
}
);
}
wrapFetch(urlprefix) {
const originalFetch = window.fetch;
const self = this;
return async function wrappedFetch(input, init = {}) {
let url;
if (typeof input === 'string') {
url = input;
} else if (input instanceof Request) {
url = input.url;
} else {
url = String(input);
}
if (url.startsWith('@')) {
url = url.slice(1)
} else if (urlprefix && !url.startsWith('http://') && !url.startsWith('https://')) {
url = urlprefix + url;
}
const headers = { ...init.headers };
const authHeaders = self.getAuthHeaders();
for (const [key, value] of Object.entries(authHeaders)) {
if (!headers[key]) {
headers[key] = value;
}
}
return originalFetch(url, { ...init, headers });
};
}
_cachePublicUser(user) {
if (!user?.id) return;

Loading…
Cancel
Save