feat(auth): replace user-level token version with session-based authentication

- Replace global user token version with per-session versioning in JWT claims
    - Add session CRUD operations with DB + Redis dual-write caching strategy
    - Create/list/revoke individual sessions and batch revoke other sessions
    - Update login flow to create sessions with device info and IP extraction
    - Update refresh flow to validate and rotate session-level token version
    - Update logout to revoke only the current session instead of all tokens
    - Add session management UI page with device/browser detection and relative time display
    - Add i18n keys for session management in both Chinese and English
    - Add sessions route and navigation menu items in both default and icon layouts
master
veypi 4 days ago
parent 99c1e0c148
commit 3913640f5b

@ -33,6 +33,11 @@ func init() {
Router.Get("/users", "搜索用户", searchUsers)
Router.Post("/users", "批量查询用户", searchUsers)
// 会话管理
Router.Get("/sessions", "获取当前用户所有会话", listSessions)
Router.Delete("/sessions", "批量撤销其他会话", revokeOtherSessions)
Router.Delete("/sessions/{id}", "撤销指定会话", revokeSession)
// 当前用户(需要认证)
meRouter := Router.SubRouter("/me")
meRouter.Get("/", "获取当前用户信息", me)

@ -7,7 +7,9 @@
package auth
import (
"net"
"net/http"
"strings"
"time"
"github.com/veypi/vbase/auth"
@ -117,14 +119,16 @@ func loginWithCode(x *vigo.X, req *LoginWithCodeRequest) (*AuthResponse, error)
return generateAuthResponseForUser(x, &user)
}
// generateAuthResponseForUser 为用户生成认证响应(登录时递增版本,踢掉旧会话
// generateAuthResponseForUser 为用户生成认证响应(创建新 session支持多点登录)
func generateAuthResponseForUser(x *vigo.X, user *models.User) (*AuthResponse, error) {
version, err := auth.IncrTokenVersion(user.ID)
deviceInfo, ip := extractDeviceInfo(x.Request)
expiresAt := time.Now().Add(cfg.Global.JWT.RefreshExpiry)
session, err := auth.CreateSession(user.ID, deviceInfo, ip, expiresAt)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
tokenPair, err := jwt.GenerateTokenPair(user.ID, version)
tokenPair, err := jwt.GenerateTokenPair(user.ID, session.ID, session.Version)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
@ -210,14 +214,14 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
return nil, vigo.ErrForbidden.WithString("user is disabled")
}
// 严格验证 refresh_token 版本并递增(版本不匹配=已泄露/已用)
newVersion, err := auth.ValidateRefreshAndRotate(claims.UserID, claims.Version)
// Session 级别版本旋转(版本不匹配=已泄露/已用)
newVersion, err := auth.ValidateRefreshSession(claims.UserID, claims.SessionID, claims.Version)
if err != nil {
return nil, vigo.ErrTokenInvalid.WithString("refresh token is stale or already used")
}
// 生成新 token pair新版本号)
tokenPair, err := jwt.GenerateTokenPair(user.ID, newVersion)
// 生成新 token pair同一 session新版本号)
tokenPair, err := jwt.GenerateTokenPair(user.ID, claims.SessionID, newVersion)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
@ -236,17 +240,17 @@ func refresh(x *vigo.X, req *RefreshRequest) (*AuthResponse, error) {
}, nil
}
// logout 用户登出(版本递增,全部 token 失效
// logout 用户登出(仅撤销当前会话
func logout(x *vigo.X) error {
userID := cfg.Auth.UserID(x)
if userID == "" {
sessionID := auth.GetCurrentSessionID(x)
if userID == "" || sessionID == "" {
clearTokenCookies(x)
return nil
}
// 递增 token 版本 → 已签发的所有 token 全部失效
if err := auth.RevokeAllTokens(userID); err != nil {
if err := auth.RevokeSession(userID, sessionID); err != nil {
return vigo.ErrInternalServer.WithError(err)
}
// 清除 Cookie
clearTokenCookies(x)
return nil
}
@ -299,3 +303,102 @@ func clearTokenCookies(x *vigo.X) {
HttpOnly: true,
})
}
// ========== 设备信息提取 ==========
// extractDeviceInfo 从请求中提取设备信息和 IP
func extractDeviceInfo(r *http.Request) (deviceInfo, ip string) {
// IP
ip = r.Header.Get("X-Forwarded-For")
if ip == "" {
ip = r.Header.Get("X-Real-IP")
}
if ip == "" {
ip = r.RemoteAddr
}
// 取第一个 IPX-Forwarded-For 可能含多个)
if idx := strings.IndexByte(ip, ','); idx != -1 {
ip = strings.TrimSpace(ip[:idx])
}
// 去掉端口号RemoteAddr 格式为 IP:port
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
// User-Agent截断到 300 字符)
ua := r.UserAgent()
if len(ua) > 300 {
ua = ua[:300]
}
deviceInfo = ua
return
}
// ========== 会话管理 API ==========
// SessionInfo 会话信息
type SessionInfo struct {
ID string `json:"id"`
DeviceInfo string `json:"device_info"`
IP string `json:"ip"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
IsCurrent bool `json:"is_current"`
}
// listSessions 获取当前用户所有活跃会话
func listSessions(x *vigo.X) ([]SessionInfo, error) {
userID := cfg.Auth.UserID(x)
if userID == "" {
return nil, vigo.ErrUnauthorized
}
sessions, err := auth.ListSessions(userID)
if err != nil {
return nil, vigo.ErrInternalServer.WithError(err)
}
currentSID := auth.GetCurrentSessionID(x)
result := make([]SessionInfo, 0, len(sessions))
for _, s := range sessions {
result = append(result, SessionInfo{
ID: s.ID,
DeviceInfo: s.DeviceInfo,
IP: s.IP,
CreatedAt: s.CreatedAt,
ExpiresAt: s.ExpiresAt,
IsCurrent: s.ID == currentSID,
})
}
return result, nil
}
// RevokeSessionRequest 撤销会话请求
type RevokeSessionRequest struct {
ID string `json:"id" src:"path" desc:"会话ID"`
}
// revokeSession 撤销指定会话
func revokeSession(x *vigo.X, req *RevokeSessionRequest) error {
userID := cfg.Auth.UserID(x)
if userID == "" {
return vigo.ErrUnauthorized
}
if err := auth.RevokeSession(userID, req.ID); err != nil {
return vigo.ErrInternalServer.WithError(err)
}
return nil
}
// revokeOtherSessions 批量撤销除当前外的所有会话
func revokeOtherSessions(x *vigo.X) error {
userID := cfg.Auth.UserID(x)
sessionID := auth.GetCurrentSessionID(x)
if userID == "" || sessionID == "" {
return vigo.ErrUnauthorized
}
if err := auth.RevokeOtherSessions(userID, sessionID); err != nil {
return vigo.ErrInternalServer.WithError(err)
}
return nil
}

@ -12,20 +12,23 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/veypi/vbase/cfg"
"github.com/veypi/vbase/libs/jwt"
"github.com/veypi/vbase/models"
"github.com/veypi/vigo"
pub "github.com/veypi/vigo/contrib/auth"
"github.com/veypi/vigo/contrib/event"
"github.com/veypi/vigo/logv"
"gorm.io/gorm"
)
const (
// CtxKeyUserID 用户ID上下文键
CtxKeyUserID = "auth:user_id"
// CtxKeySessionID 会话ID上下文键
CtxKeySessionID = "auth:session_id"
// 权限等级
LevelNone = 0
@ -129,14 +132,15 @@ func (a *vbaseProvider) UserID(x *vigo.X) string {
return ""
}
// 验证 token 版本(允许 ±1 防止多 tab 并发刷新互相踢)
if !validateTokenVersion(claims.UserID, claims.Version) {
// 验证 access token 对应的 session允许当前版本或上一版本防止多 tab 并发刷新互踢)
if !ValidateAccessSession(claims.SessionID, claims.UserID, claims.Version) {
x.Set(ctxKeyTokenParsed, true)
return ""
}
// 5. 设置到上下文中,供后续调用使用
x.Set(CtxKeyUserID, claims.UserID)
x.Set(CtxKeySessionID, claims.SessionID)
x.Set(ctxKeyTokenParsed, true)
return claims.UserID
@ -525,77 +529,231 @@ func extractToken(x *vigo.X) string {
return x.Request.URL.Query().Get("access_token")
}
// ========== Token 版本管理 ==========
// ========== Session 管理 ==========
func userTokenVersionKey(userID string) string {
return fmt.Sprintf("vb:user:%s:token_ver", userID)
func sessionKey(sid string) string {
return fmt.Sprintf("vb:session:%s", sid)
}
// getTokenVersion 获取用户当前 token 版本号
func getTokenVersion(userID string) (int64, error) {
rds := cfg.Redis()
if rds == nil {
return 0, fmt.Errorf("redis not available")
func userSessionsKey(uid string) string {
return fmt.Sprintf("vb:user_sessions:%s", uid)
}
// CreateSession 创建登录会话DB + Redis
func CreateSession(userID, deviceInfo, ip string, expiresAt time.Time) (*models.Session, error) {
session := &models.Session{
UserID: userID,
Version: 1,
DeviceInfo: deviceInfo,
IP: ip,
ExpiresAt: expiresAt,
}
val, err := rds.Get(context.Background(), userTokenVersionKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return 0, nil // 首次,版本为 0
if err := cfg.DB().Create(session).Error; err != nil {
return nil, err
}
if err != nil {
return 0, err
// 写 Redis 缓存
fillSessionCache(session)
return session, nil
}
// GetCurrentSessionID 从请求上下文获取当前 session ID
func GetCurrentSessionID(x *vigo.X) string {
if sid, ok := x.Get(CtxKeySessionID).(string); ok {
return sid
}
return strconv.ParseInt(val, 10, 64)
return ""
}
// incrTokenVersion 递增用户 token 版本号,返回新版本
func incrTokenVersion(userID string) (int64, error) {
// ValidateAccessSession 验证 access token 对应的 session允许当前版本或上一版本
func ValidateAccessSession(sessionID, userID string, tokenVer int64) bool {
// 先查 Redis
rds := cfg.Redis()
if rds == nil {
return 0, fmt.Errorf("redis not available")
if rds != nil {
revoked, err := rds.HGet(context.Background(), sessionKey(sessionID), "revoked").Result()
if err == nil {
verStr, _ := rds.HGet(context.Background(), sessionKey(sessionID), "version").Result()
suid, _ := rds.HGet(context.Background(), sessionKey(sessionID), "user_id").Result()
if revoked == "true" || suid != userID {
return false
}
newVer, err := rds.Incr(context.Background(), userTokenVersionKey(userID)).Result()
ver, err := strconv.ParseInt(verStr, 10, 64)
if err != nil {
return false
}
return tokenVer == ver || tokenVer == ver-1
}
}
// Redis 未命中,查 DB
var session models.Session
if err := cfg.DB().Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
return false
}
if session.Revoked {
return false
}
// 回填 Redis
fillSessionCache(&session)
return tokenVer == session.Version || tokenVer == session.Version-1
}
// ValidateRefreshSession 验证 refresh token 并旋转版本号,返回新版本
func ValidateRefreshSession(userID, sessionID string, tokenVer int64) (int64, error) {
var session models.Session
if err := cfg.DB().Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
return 0, fmt.Errorf("session not found")
}
if session.Revoked {
return 0, fmt.Errorf("session revoked")
}
if tokenVer != session.Version {
return 0, fmt.Errorf("refresh token version mismatch: expected %d, got %d", session.Version, tokenVer)
}
// 版本 +1同时延长 session 过期时间
newVer := session.Version + 1
newExpiresAt := time.Now().Add(cfg.Global.JWT.RefreshExpiry)
if err := cfg.DB().Model(&session).Updates(map[string]interface{}{
"version": newVer,
"expires_at": newExpiresAt,
}).Error; err != nil {
return 0, err
}
// 更新 Redis
rds := cfg.Redis()
if rds != nil {
ttl := time.Until(newExpiresAt)
if ttl > 0 {
pipe := rds.Pipeline()
pipe.HSet(context.Background(), sessionKey(sessionID), "version", newVer)
pipe.Expire(context.Background(), sessionKey(sessionID), ttl)
pipe.Expire(context.Background(), userSessionsKey(userID), ttl)
pipe.Exec(context.Background())
}
}
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
// RevokeSession 撤销指定会话
func RevokeSession(userID, sessionID string) error {
now := time.Now()
res := cfg.DB().Model(&models.Session{}).Where("id = ? AND user_id = ?", sessionID, userID).Updates(map[string]interface{}{
"revoked": true,
"revoked_at": now,
})
if res.Error != nil {
return res.Error
}
// 允许当前版本或上一版本(防止多 tab 并发刷新导致误踢)
return tokenVer == currentVer || tokenVer == currentVer-1
}
// GetTokenVersion 获取用户当前 token 版本号(导出给 api/auth 使用)
func GetTokenVersion(userID string) (int64, error) {
return getTokenVersion(userID)
// 更新 Redis
rds := cfg.Redis()
if rds != nil {
if err := rds.HSet(context.Background(), sessionKey(sessionID), "revoked", "true").Err(); err != nil {
logv.Warn().Msgf("RevokeSession: redis HSet failed: %v", err)
}
if err := rds.SRem(context.Background(), userSessionsKey(userID), sessionID).Err(); err != nil {
logv.Warn().Msgf("RevokeSession: redis SRem failed: %v", err)
}
}
return nil
}
// IncrTokenVersion 递增用户 token 版本号,返回新版本(登录时调用,实现踢旧会话)
func IncrTokenVersion(userID string) (int64, error) {
return incrTokenVersion(userID)
// RevokeOtherSessions 撤销用户除当前外的其他所有会话
func RevokeOtherSessions(userID, currentSessionID string) error {
now := time.Now()
if err := cfg.DB().Model(&models.Session{}).Where("user_id = ? AND id != ? AND revoked = ?", userID, currentSessionID, false).Updates(map[string]interface{}{
"revoked": true,
"revoked_at": now,
}).Error; err != nil {
return err
}
rds := cfg.Redis()
if rds != nil {
members, err := rds.SMembers(context.Background(), userSessionsKey(userID)).Result()
if err == nil {
for _, sid := range members {
if sid != currentSessionID {
rds.HSet(context.Background(), sessionKey(sid), "revoked", "true")
rds.SRem(context.Background(), userSessionsKey(userID), sid)
}
}
} else {
logv.Warn().Msgf("RevokeOtherSessions: redis SMembers failed: %v", err)
}
}
return nil
}
// RevokeAllTokens 撤销用户所有 token递增版本号
func RevokeAllTokens(userID string) error {
_, err := incrTokenVersion(userID)
// RevokeAllSessions 撤销用户所有会话
func RevokeAllSessions(userID string) error {
now := time.Now()
if err := cfg.DB().Model(&models.Session{}).Where("user_id = ? AND revoked = ?", userID, false).Updates(map[string]interface{}{
"revoked": true,
"revoked_at": now,
}).Error; err != nil {
return err
}
// 清理 Redis
rds := cfg.Redis()
if rds != nil {
members, err := rds.SMembers(context.Background(), userSessionsKey(userID)).Result()
if err == nil {
for _, sid := range members {
rds.Del(context.Background(), sessionKey(sid))
}
} else {
logv.Warn().Msgf("RevokeAllSessions: redis SMembers failed: %v", err)
}
if err := rds.Del(context.Background(), userSessionsKey(userID)).Err(); err != nil {
logv.Warn().Msgf("RevokeAllSessions: redis Del failed: %v", err)
}
}
return nil
}
// ValidateRefreshAndRotate 严格验证 refresh_token 版本并轮换,返回新版本号
func ValidateRefreshAndRotate(userID string, tokenVer int64) (int64, error) {
currentVer, err := getTokenVersion(userID)
if err != nil {
return 0, err
// ListSessions 列出用户所有活跃会话
func ListSessions(userID string) ([]models.Session, error) {
var sessions []models.Session
if err := cfg.DB().Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).Order("created_at DESC").Find(&sessions).Error; err != nil {
return nil, err
}
if tokenVer != currentVer {
return 0, fmt.Errorf("refresh token version mismatch: expected %d, got %d", currentVer, tokenVer)
return sessions, nil
}
// fillSessionCache 回填 Redis 缓存
func fillSessionCache(session *models.Session) {
rds := cfg.Redis()
if rds == nil {
return
}
ttl := time.Until(session.ExpiresAt)
if ttl <= 0 {
return
}
revoked := "false"
if session.Revoked {
revoked = "true"
}
pipe := rds.Pipeline()
pipe.HSet(context.Background(), sessionKey(session.ID),
"version", session.Version,
"revoked", revoked,
"user_id", session.UserID,
)
pipe.Expire(context.Background(), sessionKey(session.ID), ttl)
pipe.SAdd(context.Background(), userSessionsKey(session.UserID), session.ID)
pipe.Expire(context.Background(), userSessionsKey(session.UserID), ttl)
if _, err := pipe.Exec(context.Background()); err != nil {
logv.Warn().Msgf("fillSessionCache: redis pipeline failed: %v", err)
}
return incrTokenVersion(userID)
}
func validatePermission(code string, level int) error {

@ -27,7 +27,8 @@ type Claims struct {
jwt.RegisteredClaims
UserID string `json:"uid"`
Type string `json:"type"` // "access" / "refresh"
Version int64 `json:"ver"` // token 版本号,用于撤销
SessionID string `json:"sid"` // 归属的会话 ID
Version int64 `json:"ver"` // 会话内 token 版本号,用于撤销
}
// TokenPair Token对
@ -39,13 +40,13 @@ type TokenPair struct {
}
// GenerateTokenPair 生成token对
func GenerateTokenPair(userID string, version int64) (*TokenPair, error) {
accessToken, err := GenerateAccessToken(userID, version)
func GenerateTokenPair(userID, sessionID string, version int64) (*TokenPair, error) {
accessToken, err := GenerateAccessToken(userID, sessionID, version)
if err != nil {
return nil, err
}
refreshToken, err := GenerateRefreshToken(userID, version)
refreshToken, err := GenerateRefreshToken(userID, sessionID, version)
if err != nil {
return nil, err
}
@ -58,8 +59,8 @@ func GenerateTokenPair(userID string, version int64) (*TokenPair, error) {
}, nil
}
// GenerateAccessToken 生成访问令牌(轻量,仅存 UserID + Version
func GenerateAccessToken(userID string, version int64) (string, error) {
// GenerateAccessToken 生成访问令牌
func GenerateAccessToken(userID, sessionID string, version int64) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
@ -73,6 +74,7 @@ func GenerateAccessToken(userID string, version int64) (string, error) {
},
UserID: userID,
Type: "access",
SessionID: sessionID,
Version: version,
}
@ -81,7 +83,7 @@ func GenerateAccessToken(userID string, version int64) (string, error) {
}
// GenerateRefreshToken 生成刷新令牌
func GenerateRefreshToken(userID string, version int64) (string, error) {
func GenerateRefreshToken(userID, sessionID string, version int64) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
@ -93,6 +95,7 @@ func GenerateRefreshToken(userID string, version int64) (string, error) {
},
UserID: userID,
Type: "refresh",
SessionID: sessionID,
Version: version,
}

@ -44,15 +44,14 @@ type Identity struct {
User User `json:"user,omitempty" gorm:"foreignKey:UserID;references:ID"`
}
// Session 登录会话
// Session 登录会话(每次登录创建一个)
type Session struct {
vigo.Model
UserID string `json:"user_id" gorm:"index;not null"`
TokenID string `json:"token_id" gorm:"uniqueIndex;size:36"` // JWT jti
Type string `json:"type" gorm:"size:20;not null"` // access/refresh
DeviceInfo string `json:"device_info" gorm:"size:200"`
Version int64 `json:"version" gorm:"default:1"` // 会话内 token 版本号,用于刷新轮换
DeviceInfo string `json:"device_info" gorm:"size:300"`
IP string `json:"ip" gorm:"size:50"`
ExpiresAt time.Time `json:"expires_at"`
ExpiresAt time.Time `json:"expires_at"` // 会话过期时间(= refresh token 过期时间)
Revoked bool `json:"revoked" gorm:"default:false"`
RevokedAt *time.Time `json:"revoked_at"`

@ -84,6 +84,20 @@
"nav.oauth": "OAuth应用",
"nav.oauth_providers": "身份源管理",
"nav.profile": "个人中心",
"nav.sessions": "凭证管理",
"session.current": "当前",
"session.days_ago": "天前",
"session.empty": "暂无活跃会话",
"session.expires": "过期",
"session.hours_ago": "小时前",
"session.just_now": "刚刚",
"session.minutes_ago": "分钟前",
"session.revoke": "撤销",
"session.revoke_confirm": "确定撤销该会话吗?",
"session.revoke_others": "撤销其他会话",
"session.revoke_others_confirm": "确定撤销所有其他会话吗?",
"session.revoke_success": "会话已撤销",
"session.title": "登录凭证管理",
"nav.roles": "角色管理",
"nav.settings": "系统设置",
"nav.users": "用户管理",
@ -300,6 +314,20 @@
"nav.oauth": "OAuth Apps",
"nav.oauth_providers": "Identity Providers",
"nav.profile": "Profile",
"nav.sessions": "Sessions",
"session.current": "Current",
"session.days_ago": "days ago",
"session.empty": "No active sessions",
"session.expires": "Expires",
"session.hours_ago": "hours ago",
"session.just_now": "Just now",
"session.minutes_ago": "minutes ago",
"session.revoke": "Revoke",
"session.revoke_confirm": "Are you sure you want to revoke this session?",
"session.revoke_others": "Revoke Others",
"session.revoke_others_confirm": "Revoke all other sessions?",
"session.revoke_success": "Session revoked",
"session.title": "Session Management",
"nav.roles": "Roles",
"nav.settings": "Settings",
"nav.users": "Users",

@ -134,6 +134,7 @@
menuItems = [
{label: () => $t('nav.dashboard'), icon: "<i class='fas fa-tachometer-alt'></i>", path: "/"},
{label: () => $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
{label: () => $t('nav.sessions'), icon: "<i class='fas fa-shield-alt'></i>", path: "/sessions"},
// Admin only items would be filtered here ideally
{label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: () => $t('nav.roles'), icon: "<i class='fas fa-user-tag'></i>", path: "/roles"},

@ -150,6 +150,7 @@
menus = [
{label: () => $t('nav.dashboard'), icon: "<i class='fas fa-tachometer-alt'></i>", path: "/"},
{label: () => $t('nav.profile'), icon: "<i class='fas fa-user'></i>", path: "/profile"},
{label: () => $t('nav.sessions'), icon: "<i class='fas fa-shield-alt'></i>", path: "/sessions"},
// Admin only items would be filtered here ideally
{label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: () => $t('nav.roles'), icon: "<i class='fas fa-user-tag'></i>", path: "/roles"},

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Session Management">
<title>{{ $t('session.title') }}</title>
<style>
body {
background-color: var(--bg-color);
display: flex;
justify-content: center;
padding-top: var(--spacing-xl);
padding-bottom: var(--spacing-xl);
box-sizing: border-box;
min-height: 100vh;
}
.sessions-container {
width: 100%;
max-width: 800px;
padding: var(--spacing-xl);
background: var(--bg-color-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
height: fit-content;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.session-list {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color 0.2s;
}
.session-item:hover {
border-color: var(--color-primary);
}
.session-item.current {
border-color: var(--color-primary);
background: var(--color-primary-light, #f0f7ff);
}
.session-left {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
flex: 1;
min-width: 0;
}
.session-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
background: var(--bg-color-secondary);
color: var(--color-text-secondary);
}
.session-item.current .session-icon {
background: var(--color-primary);
color: var(--color-primary-text);
}
.session-info {
flex: 1;
min-width: 0;
}
.session-device {
font-weight: 500;
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--spacing-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
display: flex;
gap: var(--spacing-md);
margin-top: 4px;
font-size: 0.85rem;
color: var(--color-text-light);
flex-wrap: wrap;
}
.session-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.current-badge {
font-size: 0.75rem;
background: var(--color-primary);
color: var(--color-primary-text);
padding: 2px 8px;
border-radius: 10px;
margin-left: var(--spacing-xs);
white-space: nowrap;
}
.session-actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-shrink: 0;
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--color-text-light);
}
.empty-state i {
font-size: 48px;
margin-bottom: var(--spacing-md);
display: block;
}
</style>
</head>
<body>
<div class="sessions-container">
<div class="section-header">
<h2 class="section-title">{{ $t('session.title') }}</h2>
<v-btn v-if="otherSessions.length > 0" size="sm" variant="outline" color="danger" :click="revokeAllOthers">
{{ $t('session.revoke_others') }}
</v-btn>
</div>
<div v-if="loading" style="text-align: center; padding: var(--spacing-xl);">
{{ $t('common.processing') }}
</div>
<div v-else class="session-list">
<div v-if="sessions.length === 0" class="empty-state">
<i class="fas fa-shield-alt"></i>
<p>{{ $t('session.empty') }}</p>
</div>
<div v-for="s in sessions"
class="session-item"
:class="{ current: s.is_current }">
<div class="session-left">
<div class="session-icon">
<i class="fas" :class="getDeviceIcon(s.device_info)"></i>
</div>
<div class="session-info">
<div class="session-device">
{{ formatDevice(s.device_info) }}
<span v-if="s.is_current" class="current-badge">{{ $t('session.current') }}</span>
</div>
<div class="session-meta">
<span><i class="fas fa-map-marker-alt"></i> {{ s.ip || '-' }}</span>
<span><i class="fas fa-clock"></i> {{ formatLoginTime(s.created_at) }}</span>
<span v-if="s.expires_at"><i class="fas fa-hourglass-half"></i> {{ $t('session.expires') }}: {{ formatExpiry(s.expires_at) }}</span>
</div>
</div>
</div>
<div class="session-actions">
<v-btn v-if="!s.is_current" size="sm" variant="outline" color="danger" :click="() => revokeSession(s.id)">
{{ $t('session.revoke') }}
</v-btn>
</div>
</div>
</div>
</div>
</body>
<script setup>
sessions = [];
loading = true;
loadSessions = async () => {
loading = true;
try {
sessions = await $fetch('/api/auth/sessions') || [];
} catch (e) {
$message.error(e.message);
} finally {
loading = false;
}
};
revokeSession = async (id) => {
try {
await $message.confirm($t('session.revoke_confirm'));
await $fetch(`/api/auth/sessions/${id}`, { method: 'DELETE' });
$message.success($t('session.revoke_success'));
loadSessions();
} catch (e) {
if (e !== 'cancel') $message.error(e.message);
}
};
revokeAllOthers = async () => {
try {
await $message.confirm($t('session.revoke_others_confirm'));
await $fetch('/api/auth/sessions', { method: 'DELETE' });
$message.success($t('session.revoke_success'));
loadSessions();
} catch (e) {
if (e !== 'cancel') $message.error(e.message);
}
};
otherSessions = () => sessions.filter(s => !s.is_current);
getDeviceIcon = (ua) => {
if (!ua) return 'fa-desktop';
const lower = ua.toLowerCase();
if (lower.indexOf('iphone') > -1 || lower.indexOf('ipad') > -1) return 'fa-mobile-alt';
if (lower.indexOf('android') > -1) return 'fa-android';
if (lower.indexOf('macintosh') > -1 || lower.indexOf('mac os') > -1) return 'fa-apple';
if (lower.indexOf('windows') > -1) return 'fa-windows';
if (lower.indexOf('linux') > -1) return 'fa-linux';
if (lower.indexOf('chrome') > -1 || lower.indexOf('firefox') > -1 || lower.indexOf('safari') > -1) return 'fa-globe';
return 'fa-desktop';
};
formatDevice = (ua) => {
if (!ua) return '-';
// Extract browser + OS name from UA string
let name = '';
if (ua.indexOf('iPhone') > -1) {
name = 'iPhone';
} else if (ua.indexOf('iPad') > -1) {
name = 'iPad';
} else if (ua.indexOf('Android') > -1) {
name = 'Android';
} else if (ua.indexOf('Macintosh') > -1 || ua.indexOf('Mac OS') > -1) {
name = 'Mac';
} else if (ua.indexOf('Windows') > -1) {
name = 'Windows';
} else if (ua.indexOf('Linux') > -1) {
name = 'Linux';
} else {
name = 'Unknown';
}
// Browser
if (ua.indexOf('Edg') > -1) name += ' · Edge';
else if (ua.indexOf('Chrome') > -1) name += ' · Chrome';
else if (ua.indexOf('Firefox') > -1) name += ' · Firefox';
else if (ua.indexOf('Safari') > -1) name += ' · Safari';
return name;
};
parseISO = (t) => {
if (!t) return null;
// 兼容 Safari 不支持 +08:00 冒号格式
const s = t.replace(/([+-]\d{2}):(\d{2})$/, '$1$2');
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
};
formatLoginTime = (t) => {
const d = parseISO(t);
if (!d) return '-';
const now = new Date();
const diff = now - d;
if (diff < 0) return formatDate(d);
if (diff < 60000) return $t('session.just_now');
if (diff < 3600000) return Math.floor(diff / 60000) + ' ' + $t('session.minutes_ago');
if (diff < 86400000) return Math.floor(diff / 3600000) + ' ' + $t('session.hours_ago');
if (diff < 604800000) return Math.floor(diff / 86400000) + ' ' + $t('session.days_ago');
return formatDate(d);
};
formatExpiry = (t) => {
const d = parseISO(t);
if (!d) return '-';
return formatDate(d);
};
formatDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + day + ' ' + h + ':' + min;
};
</script>
<script>
$data.loadSessions();
</script>
</html>

@ -19,6 +19,7 @@ const routes = [
// User System
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
{ path: '/sessions', component: '/page/user/sessions.html', layout: 'default', meta: { auth: true } },
{
path: '/users',
component: '/page/sys/user/index.html',

Loading…
Cancel
Save