53fa259284
Deploy Server / release-deploy-server (push) Successful in 40s
会话/设备管理后端:user_sessions 会话表、JWT 加 sid 校验、按平台类限并发登录、 登录失败锁定、修复禁用账号仍可凭 refresh 续期漏洞、/auth/ping、/auth/logout、 GET /sessions、DELETE /sessions/:id(管理员强制下线)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
485 lines
14 KiB
Go
485 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/wangjia/jiu/backend/config"
|
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
|
"github.com/wangjia/jiu/backend/internal/model"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCredentials = errors.New("invalid username or password")
|
|
ErrUserInactive = errors.New("user is disabled")
|
|
ErrLicenseLocked = errors.New("license locked, please renew or contact support")
|
|
ErrPlatformNotAllowed = errors.New("该平台不允许登录")
|
|
ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试")
|
|
ErrSessionRevoked = errors.New("session revoked")
|
|
)
|
|
|
|
// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。
|
|
type DeviceInfo struct {
|
|
DeviceID string
|
|
DeviceName string
|
|
Platform string
|
|
IP string
|
|
UserAgent string
|
|
}
|
|
|
|
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
|
|
type loginLimiter struct {
|
|
mu sync.Mutex
|
|
entries map[string]*limiterEntry
|
|
}
|
|
|
|
type limiterEntry struct {
|
|
failures int
|
|
lockedTill time.Time
|
|
}
|
|
|
|
var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}}
|
|
|
|
// locked 返回该 key 是否处于锁定中。
|
|
func (l *loginLimiter) locked(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
e := l.entries[key]
|
|
return e != nil && time.Now().Before(e.lockedTill)
|
|
}
|
|
|
|
// recordFailure 记一次失败,达到阈值则锁定。
|
|
func (l *loginLimiter) recordFailure(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
e := l.entries[key]
|
|
if e == nil {
|
|
e = &limiterEntry{}
|
|
l.entries[key] = e
|
|
}
|
|
e.failures++
|
|
max := config.C.Session.MaxFailures
|
|
if max > 0 && e.failures >= max {
|
|
e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute)
|
|
e.failures = 0
|
|
}
|
|
}
|
|
|
|
// reset 登录成功后清除失败计数。
|
|
func (l *loginLimiter) reset(key string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
delete(l.entries, key)
|
|
}
|
|
|
|
type AuthService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewAuthService(db *gorm.DB) *AuthService {
|
|
return &AuthService{db: db}
|
|
}
|
|
|
|
type TokenPair struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int `json:"expires_in"` // 秒
|
|
ShopID uint64 `json:"shop_id"`
|
|
}
|
|
|
|
// Login 账号密码登录
|
|
func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) {
|
|
limiterKey := shopCode + "|" + username
|
|
if loginLim.locked(limiterKey) {
|
|
return nil, nil, ErrTooManyAttempts
|
|
}
|
|
|
|
var shop model.Shop
|
|
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
|
|
loginLim.recordFailure(limiterKey)
|
|
return nil, nil, ErrInvalidCredentials
|
|
}
|
|
|
|
var user model.User
|
|
if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username).
|
|
First(&user).Error; err != nil {
|
|
loginLim.recordFailure(limiterKey)
|
|
return nil, nil, ErrInvalidCredentials
|
|
}
|
|
|
|
if !user.IsActive {
|
|
return nil, nil, ErrUserInactive
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
loginLim.recordFailure(limiterKey)
|
|
return nil, nil, ErrInvalidCredentials
|
|
}
|
|
|
|
if err := s.checkLicenseNotLocked(shop.ID); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
|
|
pclass := model.PlatformClass(dev.Platform)
|
|
quota := s.effectiveQuota(&shop, pclass)
|
|
if quota <= 0 {
|
|
return nil, nil, ErrPlatformNotAllowed
|
|
}
|
|
|
|
sid := uuid.New().String()
|
|
now := time.Now()
|
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
// 统计该 user 在该 class 的活跃会话;超额踢最旧
|
|
var active []model.UserSession
|
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
|
Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL",
|
|
shop.ID, user.ID, pclass).
|
|
Order("last_seen_at ASC").Find(&active).Error; err != nil {
|
|
return err
|
|
}
|
|
// 需要腾出 (active+1) - quota 个位置
|
|
for i := 0; i <= len(active)-quota; i++ {
|
|
if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID).
|
|
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
sess := model.UserSession{
|
|
ShopID: shop.ID,
|
|
UserID: user.ID,
|
|
SID: sid,
|
|
DeviceID: dev.DeviceID,
|
|
DeviceName: dev.DeviceName,
|
|
Platform: dev.Platform,
|
|
PlatformClass: pclass,
|
|
IP: dev.IP,
|
|
UserAgent: dev.UserAgent,
|
|
LastSeenAt: now,
|
|
RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour),
|
|
}
|
|
if err := tx.Create(&sess).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error
|
|
}); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
loginLim.reset(limiterKey)
|
|
user.LastLoginAt = &now
|
|
|
|
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return pair, &user, nil
|
|
}
|
|
|
|
// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。
|
|
func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int {
|
|
def := map[string]int{
|
|
"desktop": config.C.Session.LimitDesktop,
|
|
"mobile": config.C.Session.LimitMobile,
|
|
"web": config.C.Session.LimitWeb,
|
|
}[pclass]
|
|
|
|
if shop.CustomFields == nil {
|
|
return def
|
|
}
|
|
raw, ok := shop.CustomFields["session_policy"]
|
|
if !ok {
|
|
return def
|
|
}
|
|
policy, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return def
|
|
}
|
|
v, ok := policy[pclass]
|
|
if !ok {
|
|
return def
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
default:
|
|
return def
|
|
}
|
|
}
|
|
|
|
// Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。
|
|
func (s *AuthService) Logout(sid string) error {
|
|
if sid == "" {
|
|
return nil
|
|
}
|
|
return s.db.Model(&model.UserSession{}).
|
|
Where("sid = ? AND revoked_at IS NULL", sid).
|
|
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error
|
|
}
|
|
|
|
// SessionView 在线会话视图(含用户名,供管理端列表展示)。
|
|
type SessionView struct {
|
|
ID uint64 `json:"id"`
|
|
UserID uint64 `json:"user_id"`
|
|
Username string `json:"username"`
|
|
RealName string `json:"real_name"`
|
|
Platform string `json:"platform"`
|
|
PlatformClass string `json:"platform_class"`
|
|
DeviceName string `json:"device_name"`
|
|
IP string `json:"ip"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
Online bool `json:"online"`
|
|
IsCurrent bool `json:"is_current"`
|
|
}
|
|
|
|
// OnlineThreshold last_seen 在此时间内视为在线。
|
|
const OnlineThreshold = 90 * time.Second
|
|
|
|
// ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。
|
|
func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) {
|
|
var rows []struct {
|
|
model.UserSession
|
|
Username string
|
|
RealName string
|
|
}
|
|
err := s.db.Table("user_sessions AS s").
|
|
Select("s.*, u.username AS username, u.real_name AS real_name").
|
|
Joins("LEFT JOIN users u ON u.id = s.user_id").
|
|
Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID).
|
|
Order("s.last_seen_at DESC").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
now := time.Now()
|
|
views := make([]SessionView, 0, len(rows))
|
|
for _, r := range rows {
|
|
views = append(views, SessionView{
|
|
ID: r.ID,
|
|
UserID: r.UserID,
|
|
Username: r.Username,
|
|
RealName: r.RealName,
|
|
Platform: r.Platform,
|
|
PlatformClass: r.PlatformClass,
|
|
DeviceName: r.DeviceName,
|
|
IP: r.IP,
|
|
CreatedAt: r.CreatedAt,
|
|
LastSeenAt: r.LastSeenAt,
|
|
Online: now.Sub(r.LastSeenAt) <= OnlineThreshold,
|
|
IsCurrent: currentSID != "" && r.SID == currentSID,
|
|
})
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。
|
|
func (s *AuthService) ForceLogout(shopID, sessionID uint64) error {
|
|
res := s.db.Model(&model.UserSession{}).
|
|
Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID).
|
|
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin"})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RegisterInput 注册新门店所需参数
|
|
type RegisterInput struct {
|
|
ShopName string `json:"shop_name" binding:"required"`
|
|
Address string `json:"address" binding:"required"`
|
|
ManagerName string `json:"manager_name" binding:"required"`
|
|
Phone string `json:"phone" binding:"required"`
|
|
Description string `json:"description"`
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
}
|
|
|
|
// RegisterResult 注册成功后返回的数据
|
|
type RegisterResult struct {
|
|
ShopCode string `json:"shop_code"`
|
|
ShopName string `json:"shop_name"`
|
|
Username string `json:"username"`
|
|
}
|
|
|
|
// Register 自助注册新门店(公开接口,无需认证)
|
|
func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result RegisterResult
|
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
// 先用 UUID 占位创建门店,得到真实 ID
|
|
shop := model.Shop{
|
|
Name: in.ShopName,
|
|
Code: uuid.New().String(),
|
|
Address: in.Address,
|
|
Phone: in.Phone,
|
|
ManagerName: in.ManagerName,
|
|
Description: in.Description,
|
|
}
|
|
if err := tx.Create(&shop).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 用自增 ID 生成正式门店编码
|
|
shop.Code = fmt.Sprintf("S%06d", shop.ID)
|
|
if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// 创建管理员用户
|
|
user := model.User{
|
|
ShopID: shop.ID,
|
|
Username: in.Username,
|
|
PasswordHash: string(hash),
|
|
RealName: in.ManagerName,
|
|
Phone: in.Phone,
|
|
Role: "superadmin",
|
|
IsActive: true,
|
|
}
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
createTrialLicense(tx, shop.ID)
|
|
|
|
result = RegisterResult{
|
|
ShopCode: shop.Code,
|
|
ShopName: shop.Name,
|
|
Username: user.Username,
|
|
}
|
|
return nil
|
|
})
|
|
return &result, err
|
|
}
|
|
|
|
// HashPassword 生成 bcrypt 哈希
|
|
func HashPassword(plain string) (string, error) {
|
|
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
|
|
return string(b), err
|
|
}
|
|
|
|
// RefreshTokens 用 Refresh Token 换新 Token Pair。
|
|
// 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。
|
|
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
|
claims := &middleware.Claims{}
|
|
token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) {
|
|
return []byte(config.C.JWT.Secret), nil
|
|
})
|
|
if err != nil || !token.Valid {
|
|
return nil, errors.New("invalid refresh token")
|
|
}
|
|
|
|
// 带 sid 的 token 必须对应未撤销会话(被踢/登出后无法续期)。
|
|
if claims.SID != "" {
|
|
var sess model.UserSession
|
|
if err := s.db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil {
|
|
return nil, ErrSessionRevoked
|
|
}
|
|
}
|
|
|
|
// 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。
|
|
var user model.User
|
|
if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil {
|
|
return nil, ErrInvalidCredentials
|
|
}
|
|
if !user.IsActive {
|
|
return nil, ErrUserInactive
|
|
}
|
|
|
|
if err := s.checkLicenseNotLocked(user.ShopID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 刷新会话存活时间(同 sid 续期)。
|
|
if claims.SID != "" {
|
|
s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID).
|
|
Update("last_seen_at", time.Now())
|
|
}
|
|
|
|
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
|
|
}
|
|
|
|
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
|
var lic model.License
|
|
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
|
Order("id DESC").First(&lic).Error; err != nil {
|
|
return nil // no license record → allow login
|
|
}
|
|
if middleware.CalcLicensePhase(lic.ExpiresAt) == middleware.PhaseLocked {
|
|
return ErrLicenseLocked
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) {
|
|
cfg := config.C.JWT
|
|
now := time.Now()
|
|
|
|
// Embed license expires_at in JWT so LicenseGuard can check phase without DB.
|
|
var licExpAt *int64
|
|
var lic model.License
|
|
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
|
Order("id DESC").First(&lic).Error; err == nil && lic.ExpiresAt != nil {
|
|
ts := lic.ExpiresAt.Unix()
|
|
licExpAt = &ts
|
|
}
|
|
|
|
accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute)
|
|
accessClaims := middleware.Claims{
|
|
UserID: userID,
|
|
ShopID: shopID,
|
|
Role: role,
|
|
SID: sid,
|
|
LicenseExpiresAt: licExpAt,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(accessExp),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
},
|
|
}
|
|
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.Secret))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour)
|
|
refreshClaims := middleware.Claims{
|
|
UserID: userID,
|
|
ShopID: shopID,
|
|
Role: role,
|
|
SID: sid,
|
|
LicenseExpiresAt: licExpAt,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(refreshExp),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
},
|
|
}
|
|
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.Secret))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &TokenPair{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
ExpiresIn: cfg.AccessExpireMin * 60,
|
|
ShopID: shopID,
|
|
}, nil
|
|
}
|