From a01b89f942a32bdb46f4f8d7cb80f821d5517ea4 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 3 Nov 2026 01:12:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E8=AE=B0=E4=BD=8F=E7=99=BB?= =?UTF-8?q?=E5=BD=95=20=E2=80=94=E2=80=94=20=E5=88=B7=E6=96=B0=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=8C=81=E4=B9=85=E5=8C=96,=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=9C=A8=E6=9C=AC=E6=9C=BA=E4=BF=9D=E5=AD=98=E6=98=8E=E6=96=87?= =?UTF-8?q?=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:新增 refresh_token 模型与服务,登录签发 refresh token,支持静默续期与登出撤销 (backend/internal/model/refresh_token.go, backend/internal/service/auth/refresh.go, auth_controller/auth_service/dto/router/jwt 配套改动) - 前端:登录页改为「记住登录」(只持久化引导令牌与用户名,不落盘密码),401 自动刷新并重试原请求 (stores/user.js, api/request.js, api/auth.js, main.js, Login.vue 配套改动) - 部署:已在容器中验证运行(镜像构建于 2026-11-03 00:50,服务 healthy) - 安全:仓库内无任何 .env/证书/密钥文件变更 --- backend/database/database.go | 1 + .../controller/auth/auth_controller.go | 54 ++++- backend/internal/dto/auth.go | 17 +- backend/internal/model/refresh_token.go | 28 +++ backend/internal/service/auth/auth_service.go | 50 +++-- backend/internal/service/auth/refresh.go | 172 ++++++++++++++++ backend/router/router.go | 3 + backend/utils/jwt.go | 10 +- frontend/src/api/auth.js | 25 +++ frontend/src/api/request.js | 30 ++- frontend/src/main.js | 5 +- frontend/src/stores/user.js | 155 ++++++++++++-- frontend/src/views/Login.vue | 189 ++---------------- 13 files changed, 531 insertions(+), 208 deletions(-) create mode 100644 backend/internal/model/refresh_token.go create mode 100644 backend/internal/service/auth/refresh.go 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 @@ @@ -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)