Files
opsx-licence/backend/internal/service/auth/refresh.go
T
Logan.Li a01b89f942
CI / backend (push) Waiting to run
feat(auth): 记住登录 —— 刷新令牌持久化,不再在本机保存明文密码
- 后端:新增 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/证书/密钥文件变更
2026-11-03 01:12:22 +08:00

173 lines
5.3 KiB
Go

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})
}