- 后端:新增 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/证书/密钥文件变更
This commit is contained in:
1 parent
4bf7fb6e5c
commit
a01b89f942
13 files changed
+531
-208
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ============ 客户端授权契约(无需认证,供外部工具调用) ============
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user