diff --git a/api/auth/login.go b/api/auth/login.go index 98d5d49..d4c6f4b 100644 --- a/api/auth/login.go +++ b/api/auth/login.go @@ -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 刷新Token(Token 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) + // 更新 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, @@ -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, + }) } diff --git a/api/auth/register.go b/api/auth/register.go index 55e6794..3e92a97 100644 --- a/api/auth/register.go +++ b/api/auth/register.go @@ -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) } diff --git a/api/auth/thirdparty.go b/api/auth/thirdparty.go index 5718140..61e1693 100644 --- a/api/auth/thirdparty.go +++ b/api/auth/thirdparty.go @@ -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) { diff --git a/auth/auth.go b/auth/auth.go index b1eed6f..44e04df 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -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 ") { - return auth[7:] - } + 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 { diff --git a/cfg/cfg.go b/cfg/cfg.go index 0b9e909..a893ff5 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -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", diff --git a/cli/main.go b/cli/main.go index ff35bba..6a12153 100644 --- a/cli/main.go +++ b/cli/main.go @@ -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()) } diff --git a/libs/jwt/jwt.go b/libs/jwt/jwt.go index 5469606..f7dcdbb 100644 --- a/libs/jwt/jwt.go +++ b/libs/jwt/jwt.go @@ -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"` + UserID string `json:"uid"` + 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{ @@ -75,12 +71,9 @@ func GenerateAccessToken(userID, username, nickname, avatar, email string) (stri NotBefore: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Global.JWT.AccessExpiry)), }, - UserID: userID, - Username: username, - Nickname: nickname, - Avatar: avatar, - Email: email, - Type: "access", + UserID: userID, + 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{ @@ -98,8 +91,9 @@ func GenerateRefreshToken(userID string) (string, error) { IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(cfg.Global.JWT.RefreshExpiry)), }, - UserID: userID, - Type: "refresh", + 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 +} diff --git a/ui/env.js b/ui/env.js index afbb91c..152bd41 100644 --- a/ui/env.js +++ b/ui/env.js @@ -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 diff --git a/ui/page/auth/login.html b/ui/page/auth/login.html index 53a37e5..cb8a06e 100644 --- a/ui/page/auth/login.html +++ b/ui/page/auth/login.html @@ -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; - } + 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) { - $mod.$auth.user = data.user; - } + if (data && data.user) { + $mod.$auth.user = data.user; $message.success($t('auth.register_success')); window.location.href = redirect; diff --git a/ui/routes.js b/ui/routes.js index 9268f11..aea1dd2 100644 --- a/ui/routes.js +++ b/ui/routes.js @@ -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(); diff --git a/ui/vbase.js b/ui/vbase.js index a453faa..a132285 100644 --- a/ui/vbase.js +++ b/ui/vbase.js @@ -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} 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} - */ + /** 绑定已有账号 */ 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} - */ + /** 绑定并注册 */ 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; - return true; - } - return false; + await this.request('POST', '/api/auth/refresh', {}); + return true; } catch (e) { - console.warn(e) - return false + return false; } } + /** 获取当前用户信息及权限 */ async fetchUser() { - const user = await this.request('GET', '/api/auth/me').catch(e => { - this.clear() - }); - this.user = user; - return user; - } - - getAuthHeaders() { - const headers = {}; - if (this.token) headers['Authorization'] = `Bearer ${this.token}`; - return headers; + try { + const user = await this.request('GET', '/api/auth/me'); + this.user = user; + return user; + } catch (e) { + this.clear(); + throw e; + } } + /** 清除登录状态 */ 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;