diff --git a/backend/database/database.go b/backend/database/database.go
index 81159a2..5254e3d 100644
--- a/backend/database/database.go
+++ b/backend/database/database.go
@@ -57,6 +57,7 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
err := db.AutoMigrate(
// 认证与系统
&model.User{}, // 管理员用户 // Logan.Li
+ &model.RefreshToken{}, // 刷新令牌(「记住登录」,只存 SHA-256 摘要) // Logan.Li
&model.Setting{}, // 系统设置 // Logan.Li
&model.AuditLog{}, // 操作日志 // Logan.Li
&model.LicenseTemplate{}, // License 模板 // Logan.Li
diff --git a/backend/internal/controller/auth/auth_controller.go b/backend/internal/controller/auth/auth_controller.go
index 4d612b6..daa793a 100644
--- a/backend/internal/controller/auth/auth_controller.go
+++ b/backend/internal/controller/auth/auth_controller.go
@@ -2,6 +2,7 @@ package auth
import (
"backend/config"
+ "backend/internal/dto"
authservice "backend/internal/service/auth"
"backend/utils"
"net/http"
@@ -24,6 +25,23 @@ func NewAuthController(db *gorm.DB, cfg *config.Config) *AuthController {
}
}
+// buildLoginResponse 把 service 结果组装成响应体(登录与刷新同构) // Logan.Li
+func buildLoginResponse(result *authservice.LoginResult) dto.LoginResponse {
+ return dto.LoginResponse{
+ Token: result.AccessToken,
+ ExpiresIn: utils.AccessTokenTTLSeconds(),
+ RefreshToken: result.RefreshToken,
+ RefreshExpiresAt: result.RefreshExpiresAt.Format("2006-01-02T15:04:05Z07:00"),
+ UserInfo: dto.UserInfoResponse{
+ ID: result.User.ID,
+ Username: result.User.Username,
+ Email: result.User.Email,
+ Avatar: result.User.Avatar,
+ Role: result.User.Role,
+ },
+ }
+}
+
// Login 管理员登录(成功/失败均记审计) // Logan.Li
func (ctrl *AuthController) Login(c *gin.Context) {
var req struct {
@@ -35,7 +53,7 @@ func (ctrl *AuthController) Login(c *gin.Context) {
return
}
- result, err := ctrl.authService.Login(req)
+ result, err := ctrl.authService.Login(dto.LoginRequest{Username: req.Username, Password: req.Password}, c.ClientIP(), c.Request.UserAgent())
if err != nil {
utils.LogAuthAction(ctrl.db, c, "login", req.Username, err.Error(), "failure")
c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)})
@@ -43,7 +61,34 @@ func (ctrl *AuthController) Login(c *gin.Context) {
}
utils.LogAuthAction(ctrl.db, c, "login", req.Username, "登录成功", "success")
- c.JSON(http.StatusOK, gin.H{"code": 0, "message": "登录成功", "data": result})
+ c.JSON(http.StatusOK, gin.H{"code": 0, "message": "登录成功", "data": buildLoginResponse(result)})
+}
+
+// Refresh 用刷新令牌换取新的访问令牌(一次性轮换,重放会撤销全部会话) // Logan.Li
+func (ctrl *AuthController) Refresh(c *gin.Context) {
+ var req dto.RefreshRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请求参数错误: " + err.Error()})
+ return
+ }
+
+ result, err := ctrl.authService.Refresh(req.RefreshToken, c.ClientIP(), c.Request.UserAgent())
+ if err != nil {
+ utils.LogAuthAction(ctrl.db, c, "refresh", "-", err.Error(), "failure")
+ c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": utils.InternalMsg(err)})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"code": 0, "message": "刷新成功", "data": buildLoginResponse(result)})
+}
+
+// Logout 登出:撤销刷新令牌(幂等,未带令牌也返回成功) // Logan.Li
+func (ctrl *AuthController) Logout(c *gin.Context) {
+ var req dto.LogoutRequest
+ _ = c.ShouldBindJSON(&req) // 允许空 body:前端丢了本地令牌也要能登出 // Logan.Li
+ ctrl.authService.Revoke(req.RefreshToken)
+ utils.LogAuthAction(ctrl.db, c, "logout", "-", "退出登录", "success")
+ c.JSON(http.StatusOK, gin.H{"code": 0, "message": "已退出登录"})
}
// Me 获取当前登录用户信息 // Logan.Li
@@ -72,7 +117,10 @@ func (ctrl *AuthController) ChangePassword(c *gin.Context) {
return
}
- if err := ctrl.authService.ChangePassword(userID.(uint), req); err != nil {
+ if err := ctrl.authService.ChangePassword(userID.(uint), dto.ChangePasswordRequest{
+ OldPassword: req.OldPassword,
+ NewPassword: req.NewPassword,
+ }); err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": utils.InternalMsg(err)})
return
}
diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go
index 7357c40..50ebc32 100644
--- a/backend/internal/dto/auth.go
+++ b/backend/internal/dto/auth.go
@@ -8,8 +8,21 @@ type LoginRequest struct {
// LoginResponse 登录响应 // Logan.Li
type LoginResponse struct {
- Token string `json:"token"`
- UserInfo UserInfoResponse `json:"user_info"`
+ Token string `json:"token"`
+ ExpiresIn int64 `json:"expires_in"` // 访问令牌有效期(秒)
+ RefreshToken string `json:"refresh_token"` // 一次性返回,前端按需本地保存
+ RefreshExpiresAt string `json:"refresh_expires_at"` // 刷新令牌到期时间(RFC3339)
+ UserInfo UserInfoResponse `json:"user_info"`
+}
+
+// RefreshRequest 刷新访问令牌请求 // Logan.Li
+type RefreshRequest struct {
+ RefreshToken string `json:"refresh_token" binding:"required"`
+}
+
+// LogoutRequest 登出请求(撤销刷新令牌,缺省也返回成功) // Logan.Li
+type LogoutRequest struct {
+ RefreshToken string `json:"refresh_token"`
}
// UserInfoResponse 用户信息响应 // Logan.Li
diff --git a/backend/internal/model/refresh_token.go b/backend/internal/model/refresh_token.go
new file mode 100644
index 0000000..b9022ca
--- /dev/null
+++ b/backend/internal/model/refresh_token.go
@@ -0,0 +1,28 @@
+package model
+
+import "time"
+
+// RefreshToken 刷新令牌(支撑「记住登录」,库内只存 SHA-256 摘要,明文仅返回一次)。
+//
+// 设计要点:
+// 1. 一次性轮换:每次刷新都签发新令牌并撤销旧令牌,旧令牌的 RotatedTo 记录新令牌摘要;
+// 2. 重放检测:已撤销的令牌再次出现 → 视为令牌泄漏,撤销该用户全部刷新令牌(强制重登);
+// 3. 与 users.token_valid_after 配合:改密码后旧令牌的 refresh 也会被主动撤销。 // Logan.Li
+type RefreshToken struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ UserID uint `json:"user_id" gorm:"index;not null"`
+ TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` // SHA-256(明文令牌) 十六进制
+ ExpiresAt time.Time `json:"expires_at" gorm:"index;not null"`
+ RevokedAt *time.Time `json:"revoked_at,omitempty"`
+ RotatedTo string `json:"-" gorm:"size:64"` // 轮换后的新令牌摘要(重放链追踪)
+ IP string `json:"ip" gorm:"size:64"`
+ UserAgent string `json:"user_agent" gorm:"size:255"`
+ LastUsed *time.Time `json:"last_used,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// TableName 指定表名,避开 GORM 复数推导 // Logan.Li
+func (RefreshToken) TableName() string {
+ return "refresh_tokens"
+}
diff --git a/backend/internal/service/auth/auth_service.go b/backend/internal/service/auth/auth_service.go
index 58db181..d76b306 100644
--- a/backend/internal/service/auth/auth_service.go
+++ b/backend/internal/service/auth/auth_service.go
@@ -29,9 +29,26 @@ func NewAuthService(db *gorm.DB, cfg *config.Config) *AuthService {
return &AuthService{db: db, cfg: cfg}
}
+// LoginResult 登录/刷新成功后的令牌与用户信息(供 controller 组装响应) // Logan.Li
+type LoginResult struct {
+ AccessToken string
+ RefreshToken string
+ RefreshExpiresAt time.Time
+ User *model.User
+}
+
+// issueAccessToken 签发访问令牌(HS256,24h) // Logan.Li
+func (s *AuthService) issueAccessToken(user *model.User) (string, error) {
+ token, err := utils.GenerateToken(user.ID, user.Username, user.Role, s.cfg.JWTSecret)
+ if err != nil {
+ return "", errors.New("令牌生成失败")
+ }
+ return token, nil
+}
+
// Login 校验账号密码并签发 JWT。
// 含账号级防护:连续失败达上限后锁定一段时间;成功登录清零计数。 // Logan.Li
-func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) {
+func (s *AuthService) Login(req dto.LoginRequest, ip, userAgent string) (*LoginResult, error) {
var user model.User
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
return nil, errors.New("用户名或密码错误")
@@ -64,21 +81,21 @@ func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) {
now := time.Now()
s.db.Model(&user).Update("last_login", now)
- // 签发 JWT // Logan.Li
- token, err := utils.GenerateToken(user.ID, user.Username, user.Role, s.cfg.JWTSecret)
+ // 签发访问令牌 + 刷新令牌(支撑「记住登录」,密码不落盘) // Logan.Li
+ accessToken, err := s.issueAccessToken(&user)
if err != nil {
- return nil, errors.New("令牌生成失败")
+ return nil, err
+ }
+ refreshToken, refreshExpiresAt, err := s.IssueRefreshToken(user.ID, ip, userAgent)
+ if err != nil {
+ return nil, err
}
- return &dto.LoginResponse{
- Token: token,
- UserInfo: dto.UserInfoResponse{
- ID: user.ID,
- Username: user.Username,
- Email: user.Email,
- Avatar: user.Avatar,
- Role: user.Role,
- },
+ return &LoginResult{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ RefreshExpiresAt: refreshExpiresAt,
+ User: &user,
}, nil
}
@@ -125,5 +142,10 @@ func (s *AuthService) ChangePassword(userID uint, req dto.ChangePasswordRequest)
user.TokenValidAfter = time.Now() // 旧 JWT 全部失效,需重新登录 // Logan.Li
user.FailedLoginAttempts = 0
user.LockedUntil = nil
- return s.db.Save(&user).Error
+ if err := s.db.Save(&user).Error; err != nil {
+ return err
+ }
+ // 刷新令牌一并撤销,否则「记住登录」会绕过改密失效基线 // Logan.Li
+ s.RevokeAllForUser(user.ID)
+ return nil
}
diff --git a/backend/internal/service/auth/refresh.go b/backend/internal/service/auth/refresh.go
new file mode 100644
index 0000000..57fabf5
--- /dev/null
+++ b/backend/internal/service/auth/refresh.go
@@ -0,0 +1,172 @@
+package auth
+
+import (
+ "backend/internal/model"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// 刷新令牌参数:不入库明文,只存 SHA-256;有效期默认 30 天(REFRESH_TOKEN_TTL_DAYS 可调) // Logan.Li
+const (
+ // refreshTokenPrefix 令牌前缀,便于在日志/抓包里识别类型 // Logan.Li
+ refreshTokenPrefix = "opr_"
+ // defaultRefreshTTLDays 默认有效期(天) // Logan.Li
+ defaultRefreshTTLDays = 30
+ // maxRefreshTTLDays 有效期上限(天),防止配置成永久 // Logan.Li
+ maxRefreshTTLDays = 365
+)
+
+// refreshTokenTTL 刷新令牌有效期,读环境变量 REFRESH_TOKEN_TTL_DAYS(非法值回落默认 30 天) // Logan.Li
+func refreshTokenTTL() time.Duration {
+ days := defaultRefreshTTLDays
+ if raw := strings.TrimSpace(os.Getenv("REFRESH_TOKEN_TTL_DAYS")); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= maxRefreshTTLDays {
+ days = n
+ }
+ }
+ return time.Duration(days) * 24 * time.Hour
+}
+
+// hashRefreshToken 计算令牌摘要(SHA-256 十六进制) // Logan.Li
+func hashRefreshToken(raw string) string {
+ sum := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(sum[:])
+}
+
+// newRefreshTokenValue 生成 256 位随机令牌 // Logan.Li
+func newRefreshTokenValue() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return refreshTokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// truncate 截断超长 UA,避免撑爆列宽 // Logan.Li
+func truncate(text string, limit int) string {
+ if len(text) <= limit {
+ return text
+ }
+ return text[:limit]
+}
+
+// IssueRefreshToken 签发刷新令牌(返回明文与到期时间;库里只留摘要) // Logan.Li
+func (s *AuthService) IssueRefreshToken(userID uint, ip, userAgent string) (string, time.Time, error) {
+ raw, err := newRefreshTokenValue()
+ if err != nil {
+ return "", time.Time{}, errors.New("刷新令牌生成失败")
+ }
+ now := time.Now()
+ expiresAt := now.Add(refreshTokenTTL())
+ row := model.RefreshToken{
+ UserID: userID,
+ TokenHash: hashRefreshToken(raw),
+ ExpiresAt: expiresAt,
+ IP: truncate(ip, 64),
+ UserAgent: truncate(userAgent, 255),
+ }
+ if err := s.db.Create(&row).Error; err != nil {
+ return "", time.Time{}, errors.New("刷新令牌保存失败")
+ }
+ // 顺手清理该用户的过期令牌,避免表无限增长 // Logan.Li
+ s.db.Where("user_id = ? AND expires_at < ?", userID, now).Delete(&model.RefreshToken{})
+ return raw, expiresAt, nil
+}
+
+// Refresh 用刷新令牌换取新的访问令牌(一次性轮换 + 重放检测)。
+// 返回值与登录响应同构,前端只需替换本地令牌对。 // Logan.Li
+func (s *AuthService) Refresh(raw, ip, userAgent string) (*LoginResult, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, errors.New("缺少刷新令牌")
+ }
+
+ var row model.RefreshToken
+ if err := s.db.Where("token_hash = ?", hashRefreshToken(raw)).First(&row).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, errors.New("登录状态已失效,请重新登录")
+ }
+ return nil, errors.New("读取登录状态失败")
+ }
+
+ // 重放检测:正常轮换后旧令牌必然已撤销,再次使用说明令牌被复制 → 撤销该用户全部令牌 // Logan.Li
+ if row.RevokedAt != nil {
+ s.RevokeAllForUser(row.UserID)
+ return nil, errors.New("登录状态已失效,请重新登录")
+ }
+ if !row.ExpiresAt.After(time.Now()) {
+ s.revokeRow(row.ID)
+ return nil, errors.New("登录状态已过期,请重新登录")
+ }
+
+ var user model.User
+ if err := s.db.First(&user, row.UserID).Error; err != nil {
+ return nil, errors.New("账号不存在,请重新登录")
+ }
+ if !user.IsActive {
+ return nil, errors.New("账号已被禁用")
+ }
+
+ accessToken, err := s.issueAccessToken(&user)
+ if err != nil {
+ return nil, err
+ }
+ newRaw, expiresAt, err := s.IssueRefreshToken(row.UserID, ip, userAgent)
+ if err != nil {
+ return nil, err
+ }
+
+ // 旧令牌置为已撤销,并记录轮换链 // Logan.Li
+ now := time.Now()
+ s.db.Model(&model.RefreshToken{}).Where("id = ?", row.ID).Updates(map[string]interface{}{
+ "revoked_at": &now,
+ "rotated_to": hashRefreshToken(newRaw),
+ "last_used": &now,
+ })
+
+ return &LoginResult{
+ AccessToken: accessToken,
+ RefreshToken: newRaw,
+ RefreshExpiresAt: expiresAt,
+ User: &user,
+ }, nil
+}
+
+// Revoke 撤销指定刷新令牌(登出;幂等,找不到也返回成功) // Logan.Li
+func (s *AuthService) Revoke(raw string) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return
+ }
+ now := time.Now()
+ s.db.Model(&model.RefreshToken{}).
+ Where("token_hash = ? AND revoked_at IS NULL", hashRefreshToken(raw)).
+ Updates(map[string]interface{}{"revoked_at": &now})
+}
+
+// RevokeAllForUser 撤销该用户全部刷新令牌(改密 / 重放检测 / 禁用账号时调用) // Logan.Li
+func (s *AuthService) RevokeAllForUser(userID uint) {
+ if userID == 0 {
+ return
+ }
+ now := time.Now()
+ s.db.Model(&model.RefreshToken{}).
+ Where("user_id = ? AND revoked_at IS NULL", userID).
+ Updates(map[string]interface{}{"revoked_at": &now})
+}
+
+// revokeRow 按主键撤销单行(内部用) // Logan.Li
+func (s *AuthService) revokeRow(id uint) {
+ now := time.Now()
+ s.db.Model(&model.RefreshToken{}).Where("id = ?", id).
+ Updates(map[string]interface{}{"revoked_at": &now})
+}
diff --git a/backend/router/router.go b/backend/router/router.go
index 9098697..84eae5f 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -124,6 +124,9 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
authGroup := api.Group("/auth")
{
register(authGroup, "POST", "/login", middleware.RateLimit(0.2, 5), authController.Login)
+ // 刷新/登出按刷新令牌鉴权,不需要访问令牌(访问令牌可能已过期) // Logan.Li
+ register(authGroup, "POST", "/refresh", middleware.RateLimit(0.5, 20), authController.Refresh)
+ register(authGroup, "POST", "/logout", middleware.RateLimit(0.5, 20), authController.Logout)
}
// ============ 客户端授权契约(无需认证,供外部工具调用) ============
diff --git a/backend/utils/jwt.go b/backend/utils/jwt.go
index 8f45f4a..69d1f69 100644
--- a/backend/utils/jwt.go
+++ b/backend/utils/jwt.go
@@ -15,6 +15,14 @@ type Claims struct {
jwt.RegisteredClaims
}
+// AccessTokenTTL 访问令牌有效期(配合刷新令牌使用,过期后由前端静默刷新) // Logan.Li
+const AccessTokenTTL = 24 * time.Hour
+
+// AccessTokenTTLSeconds 访问令牌有效期(秒,供响应体 expires_in 使用) // Logan.Li
+func AccessTokenTTLSeconds() int64 {
+ return int64(AccessTokenTTL / time.Second)
+}
+
// GenerateToken 生成 JWT 令牌(HS256,24h 过期) // Logan.Li
func GenerateToken(userID uint, username, role, secret string) (string, error) {
claims := Claims{
@@ -22,7 +30,7 @@ func GenerateToken(userID uint, username, role, secret string) (string, error) {
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "opsx-licence",
},
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index 902b71a..c98d8b2 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -1,5 +1,14 @@
+import axios from 'axios'
import request from './request'
+// 认证相关的裸客户端:刷新/登出**不能**走 request 实例,
+// 否则刷新失败触发的 401 拦截会再次调用刷新,形成递归。 // Logan.Li
+const authClient = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
+ timeout: 15000,
+ headers: { 'Content-Type': 'application/json' },
+})
+
// 登录 // Logan.Li
export function login(data) {
return request({
@@ -9,6 +18,22 @@ export function login(data) {
})
}
+// 用刷新令牌换取新的访问令牌(响应与登录同构:token + refresh_token + user_info) // Logan.Li
+export async function refresh(data) {
+ const res = await authClient.post('/auth/refresh', data)
+ const body = res.data || {}
+ if (body.code !== 0 && body.code !== 200) {
+ throw new Error(body.message || '登录状态已失效')
+ }
+ return body
+}
+
+// 登出:撤销服务端刷新令牌(失败不阻断本地清理) // Logan.Li
+export async function logout(data) {
+ const res = await authClient.post('/auth/logout', data)
+ return res.data
+}
+
// 获取当前用户信息 // Logan.Li
export function getMe() {
return request({
diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js
index 0324b8a..0cf82db 100644
--- a/frontend/src/api/request.js
+++ b/frontend/src/api/request.js
@@ -14,13 +14,37 @@ const request = axios.create({
let handling401 = false
+// 401 兜底:先用刷新令牌静默换新访问令牌并重放原请求,
+// 刷新失败才退回登录页 —— 这样「记住登录」的用户不会被中途踢出。 // Logan.Li
+async function retryAfterRefresh(config) {
+ if (!config || config._retried) return null
+ const userStore = useUserStore()
+ if (!userStore.refreshToken) return null
+ config._retried = true
+ try {
+ await userStore.refreshAccessToken()
+ } catch {
+ return null
+ }
+ // 重新走一遍请求拦截器,注入刷新后的 Bearer // Logan.Li
+ delete config.headers.Authorization
+ return request(config)
+}
+
+// 401 统一出口:能刷新就刷新重放,否则强制重新登录 // Logan.Li
+async function handleUnauthorized(config) {
+ const retried = await retryAfterRefresh(config)
+ if (retried) return retried
+ return forceReLogin()
+}
+
// 统一强制重新登录,避免并发 401 叠加弹窗 // Logan.Li
async function forceReLogin() {
if (handling401) return Promise.reject(new Error('relogin'))
handling401 = true
try {
const userStore = useUserStore()
- userStore.logout()
+ userStore.clearLocal()
ElMessage.warning('登录状态已过期,请重新登录')
const redirect = router.currentRoute.value.fullPath
await router.push({ path: '/login', query: redirect && redirect !== '/login' ? { redirect } : {} })
@@ -60,7 +84,7 @@ request.interceptors.response.use(
// 401:登录态失效 // Logan.Li
if (res.code === 401) {
- return forceReLogin()
+ return handleUnauthorized(response.config)
}
// 其他业务错误:仅错误用 ElMessage 提示(常规操作不打扰) // Logan.Li
@@ -69,7 +93,7 @@ request.interceptors.response.use(
},
async (error) => {
if (error.response?.status === 401) {
- return forceReLogin()
+ return handleUnauthorized(error.config)
}
let message = '网络错误'
diff --git a/frontend/src/main.js b/frontend/src/main.js
index dd99b6d..0fc07b9 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -24,4 +24,7 @@ app.use(ElementPlus, { locale: zhCn })
const userStore = useUserStore()
userStore.restore()
-app.mount('#app')
+// 启动引导:访问令牌过期时先用刷新令牌静默续期,再挂载应用(避免刚打开就跳登录页) // Logan.Li
+userStore.bootstrap().finally(() => {
+ app.mount('#app')
+})
diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js
index 342911c..31785ff 100644
--- a/frontend/src/stores/user.js
+++ b/frontend/src/stores/user.js
@@ -1,31 +1,144 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
-import { login as loginApi, getMe } from '@/api/auth'
+import {
+ login as loginApi,
+ getMe,
+ refresh as refreshApi,
+ logout as logoutApi,
+} from '@/api/auth'
const TOKEN_KEY = 'opsx_licence_token'
+const REFRESH_KEY = 'opsx_licence_refresh'
+const REMEMBER_KEY = 'opsx_licence_remember_login'
+// 旧版本「记住密码」遗留键(存的是混淆后的账号密码),升级后必须清掉 // Logan.Li
+const LEGACY_PASSWORD_KEY = 'opsx_licence_remember'
+const LAST_USERNAME_KEY = 'opsx_licence_last_username'
+
+// 读取键值(持久存储优先,其次会话存储) // Logan.Li
+function readKey(key) {
+ return localStorage.getItem(key) || sessionStorage.getItem(key) || ''
+}
+
+// 清除两个存储里的同一个键 // Logan.Li
+function dropKey(key) {
+ localStorage.removeItem(key)
+ sessionStorage.removeItem(key)
+}
+
+// 解析 JWT 到期时间,判断是否已过期(留 30s 余量) // Logan.Li
+function isExpired(token, skewMs = 30000) {
+ try {
+ const payload = JSON.parse(
+ atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
+ )
+ if (!payload?.exp) return false
+ return payload.exp * 1000 <= Date.now() + skewMs
+ } catch {
+ return true
+ }
+}
// 用户状态 store(setup 语法) // Logan.Li
export const useUserStore = defineStore('user', () => {
const token = ref('')
+ const refreshToken = ref('')
const userInfo = ref(null)
+ const rememberLogin = ref(false)
- // 从 localStorage 恢复登录状态 // Logan.Li
+ // 令牌落盘:勾选「记住登录」进 localStorage(长存),否则进 sessionStorage(关浏览器即失效) // Logan.Li
+ function persistTokens(accessToken, refresh, remember) {
+ dropKey(TOKEN_KEY)
+ dropKey(REFRESH_KEY)
+ const storage = remember ? localStorage : sessionStorage
+ if (accessToken) storage.setItem(TOKEN_KEY, accessToken)
+ if (refresh) storage.setItem(REFRESH_KEY, refresh)
+ if (remember) {
+ localStorage.setItem(REMEMBER_KEY, '1')
+ } else {
+ localStorage.removeItem(REMEMBER_KEY)
+ }
+ rememberLogin.value = !!remember
+ }
+
+ // 清空本地登录状态(含旧版本遗留的明文密码键) // Logan.Li
+ function clearLocal() {
+ dropKey(TOKEN_KEY)
+ dropKey(REFRESH_KEY)
+ localStorage.removeItem(REMEMBER_KEY)
+ localStorage.removeItem(LEGACY_PASSWORD_KEY)
+ token.value = ''
+ refreshToken.value = ''
+ userInfo.value = null
+ rememberLogin.value = false
+ }
+
+ // 从本地存储恢复登录状态 // Logan.Li
function restore() {
- const saved = localStorage.getItem(TOKEN_KEY)
- if (saved) {
- token.value = saved
+ clearLegacyPassword()
+ token.value = readKey(TOKEN_KEY)
+ refreshToken.value = readKey(REFRESH_KEY)
+ rememberLogin.value = localStorage.getItem(REMEMBER_KEY) === '1'
+ }
+
+ // 清理旧版本「记住密码」留下的账号密码(幂等,可重复调用) // Logan.Li
+ function clearLegacyPassword() {
+ if (localStorage.getItem(LEGACY_PASSWORD_KEY) !== null) {
+ localStorage.removeItem(LEGACY_PASSWORD_KEY)
}
}
- // 登录:保存 token 并拉取用户信息 // Logan.Li
- async function login(loginForm) {
+ // 登录:保存令牌对并拉取用户信息;remember 决定令牌存哪 // Logan.Li
+ async function login(loginForm, remember = false) {
const res = await loginApi(loginForm)
- token.value = res.data.token
- userInfo.value = res.data.user_info
- localStorage.setItem(TOKEN_KEY, token.value)
+ const data = res.data || {}
+ token.value = data.token
+ refreshToken.value = data.refresh_token || ''
+ userInfo.value = data.user_info
+ persistTokens(token.value, refreshToken.value, remember)
return res
}
+ // 静默刷新:并发调用共享同一个 promise,避免重复轮换导致令牌互踢 // Logan.Li
+ let refreshing = null
+ function refreshAccessToken() {
+ if (refreshing) return refreshing
+ const current = refreshToken.value || readKey(REFRESH_KEY)
+ if (!current) return Promise.reject(new Error('no-refresh-token'))
+ refreshing = refreshApi({ refresh_token: current })
+ .then((body) => {
+ const data = body.data || {}
+ token.value = data.token
+ refreshToken.value = data.refresh_token || ''
+ if (data.user_info) userInfo.value = data.user_info
+ persistTokens(token.value, refreshToken.value, rememberLogin.value)
+ return token.value
+ })
+ .catch((err) => {
+ clearLocal()
+ throw err
+ })
+ .finally(() => {
+ refreshing = null
+ })
+ return refreshing
+ }
+
+ // 启动引导:访问令牌缺失或已过期时,尝试用刷新令牌换一对新令牌 // Logan.Li
+ async function bootstrap() {
+ restore()
+ if (token.value && !isExpired(token.value)) return true
+ if (!refreshToken.value) {
+ if (token.value) clearLocal()
+ return false
+ }
+ try {
+ await refreshAccessToken()
+ return true
+ } catch {
+ return false
+ }
+ }
+
// 拉取当前用户信息 // Logan.Li
async function fetchUserInfo() {
const res = await getMe()
@@ -33,19 +146,31 @@ export const useUserStore = defineStore('user', () => {
return res.data
}
- // 登出:清空状态 // Logan.Li
- function logout() {
- token.value = ''
- userInfo.value = null
- localStorage.removeItem(TOKEN_KEY)
+ // 登出:先撤销服务端刷新令牌,再清本地(远端失败也照常清) // Logan.Li
+ async function logout() {
+ const current = refreshToken.value || readKey(REFRESH_KEY)
+ clearLocal()
+ if (current) {
+ try {
+ await logoutApi({ refresh_token: current })
+ } catch {
+ // 网络异常不影响本地登出 // Logan.Li
+ }
+ }
}
return {
token,
+ refreshToken,
userInfo,
+ rememberLogin,
restore,
+ bootstrap,
login,
+ refreshAccessToken,
fetchUserInfo,
logout,
+ clearLegacyPassword,
+ LAST_USERNAME_KEY,
}
})
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index aa1680a..b8f8ee1 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -1,12 +1,11 @@
-
@@ -50,8 +49,7 @@
{{ loading ? '登录中…' : '登 录' }}
-
-
+
@@ -68,7 +66,9 @@ const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
-const rememberPassword = ref(false)
+// 「记住登录」只影响刷新令牌的存放位置(localStorage / sessionStorage),
+// 账号密码一律不落盘 —— 本机只保存服务端可撤销的登录凭据。 // Logan.Li
+const rememberLogin = ref(false)
const loginForm = reactive({
username: '',
@@ -81,63 +81,16 @@ const loginRules = {
}
const rememberHint = computed(() =>
- rememberPassword.value ? '已在本机保存,下次自动填充' : '勾选后本机自动填充账号密码'
+ rememberLogin.value ? '30 天内免登录(不保存密码)' : '关闭浏览器后需重新登录'
)
-// ============ 「记住密码」本地存储 ============
-// 仅保存在本机浏览器;做一层 XOR + Base64 混淆,避免明文躺在 localStorage 里被随手看到。
-// ⚠️ 这是混淆而非加密,公共设备请勿勾选。 // Logan.Li
-const REMEMBER_KEY = 'opsx_licence_remember'
-const REMEMBER_USERNAME_KEY = 'opsx_licence_last_username'
-const XOR_KEY = 0x2f
-
-function obfuscate(text) {
- const xored = Array.from(text)
- .map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
- .join('')
- return btoa(encodeURIComponent(xored))
-}
-
-function deobfuscate(token) {
- const xored = decodeURIComponent(atob(token))
- return Array.from(xored)
- .map((ch) => String.fromCharCode(ch.charCodeAt(0) ^ XOR_KEY))
- .join('')
-}
-
-// 读取上次的账号 / 密码 // Logan.Li
-function restoreRemembered() {
- try {
- const lastUser = localStorage.getItem(REMEMBER_USERNAME_KEY)
- if (lastUser) loginForm.username = lastUser
-
- const saved = localStorage.getItem(REMEMBER_KEY)
- if (!saved) return
- const data = JSON.parse(deobfuscate(saved))
- if (data?.u) loginForm.username = data.u
- if (data?.p) {
- loginForm.password = data.p
- rememberPassword.value = true
- }
- } catch {
- localStorage.removeItem(REMEMBER_KEY)
- }
-}
-
-// 保存 / 清理记住的凭据 // Logan.Li
-function persistRemembered() {
- const username = loginForm.username.trim()
- if (username) {
- localStorage.setItem(REMEMBER_USERNAME_KEY, username)
- }
- if (rememberPassword.value && username) {
- localStorage.setItem(
- REMEMBER_KEY,
- obfuscate(JSON.stringify({ u: username, p: loginForm.password }))
- )
- } else {
- localStorage.removeItem(REMEMBER_KEY)
- }
+// 回填上次登录的用户名(只记用户名,不记密码) // Logan.Li
+function restoreLastUsername() {
+ // 清掉旧版本「记住密码」留下的混淆账号密码 // Logan.Li
+ userStore.clearLegacyPassword()
+ const lastUser = localStorage.getItem(userStore.LAST_USERNAME_KEY)
+ if (lastUser) loginForm.username = lastUser
+ rememberLogin.value = userStore.rememberLogin
}
// 处理登录 // Logan.Li
@@ -147,11 +100,10 @@ async function handleLogin() {
if (!valid) return
loading.value = true
try {
- await userStore.login({
- username: loginForm.username.trim(),
- password: loginForm.password,
- })
- persistRemembered()
+ const username = loginForm.username.trim()
+ await userStore.login({ username, password: loginForm.password }, rememberLogin.value)
+ localStorage.setItem(userStore.LAST_USERNAME_KEY, username)
+ loginForm.password = ''
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
router.push(redirect)
} catch {
@@ -162,7 +114,7 @@ async function handleLogin() {
})
}
-onMounted(restoreRemembered)
+onMounted(restoreLastUsername)