7bbc944ae2
Deploy Server / release-deploy-server (push) Successful in 51s
服务端安全加固:多维限流(按 IP/按门店)+ 敏感接口独立速率上限抵御 DDoS/刷接口; 登录暴力破解新增按来源 IP 锁定;反代后正确识别真实客户端 IP; 门店 custom_fields 轻量配置(录入默认值)透传保存。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
727 lines
24 KiB
Go
727 lines
24 KiB
Go
package service
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"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")
|
||
|
||
// errRefreshReuse 内部哨兵:在续期事务内检测到 refresh token 重用,
|
||
// 用于让调用方在事务回滚后于事务外提交「吊销整条会话」。
|
||
errRefreshReuse = errors.New("refresh token reuse detected")
|
||
)
|
||
|
||
// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。
|
||
type DeviceInfo struct {
|
||
DeviceID string
|
||
DeviceName string
|
||
Platform string
|
||
IP string
|
||
UserAgent string
|
||
}
|
||
|
||
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
|
||
// 两个维度共用同一张表:账号维度 key="<shopCode>|<username>",IP 维度 key="ip|<addr>",
|
||
// 分别用不同阈值锁定。带 janitor 清理空闲 entry,避免攻击者用随机 key 灌爆内存。
|
||
type loginLimiter struct {
|
||
mu sync.Mutex
|
||
entries map[string]*limiterEntry
|
||
janitorOnce sync.Once
|
||
}
|
||
|
||
type limiterEntry struct {
|
||
failures int
|
||
lockedTill time.Time
|
||
lastSeen time.Time
|
||
}
|
||
|
||
// loginLimiterIdleTTL:已解锁且超过该时长未活动的 entry 会被 janitor 清理。
|
||
const loginLimiterIdleTTL = 30 * time.Minute
|
||
|
||
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]
|
||
if e == nil {
|
||
return false
|
||
}
|
||
e.lastSeen = time.Now()
|
||
return time.Now().Before(e.lockedTill)
|
||
}
|
||
|
||
// recordFailure 记一次失败,达到 max 阈值则锁定(max<=0 表示该维度不锁)。
|
||
func (l *loginLimiter) recordFailure(key string, max int) {
|
||
l.startJanitor()
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
e := l.entries[key]
|
||
if e == nil {
|
||
e = &limiterEntry{}
|
||
l.entries[key] = e
|
||
}
|
||
e.lastSeen = time.Now()
|
||
e.failures++
|
||
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)
|
||
}
|
||
|
||
// startJanitor 惰性启动后台清理(仅一次):每 5 分钟淘汰「未锁定且超过 TTL 未活动」的 entry。
|
||
func (l *loginLimiter) startJanitor() {
|
||
l.janitorOnce.Do(func() {
|
||
go func() {
|
||
t := time.NewTicker(5 * time.Minute)
|
||
defer t.Stop()
|
||
for range t.C {
|
||
now := time.Now()
|
||
l.mu.Lock()
|
||
for k, e := range l.entries {
|
||
if now.After(e.lockedTill) && now.Sub(e.lastSeen) > loginLimiterIdleTTL {
|
||
delete(l.entries, k)
|
||
}
|
||
}
|
||
l.mu.Unlock()
|
||
}
|
||
}()
|
||
})
|
||
}
|
||
|
||
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) {
|
||
// 两个限流维度:账号维度防针对单账号的密码爆破;IP 维度(阈值更高)防单 IP 撞多个账号、
|
||
// 以及用随机账号灌爆内存。IP 为空(本地/测试)时退化为仅账号维度。
|
||
limiterKey := shopCode + "|" + username
|
||
ipKey := ""
|
||
if dev.IP != "" {
|
||
ipKey = "ip|" + dev.IP
|
||
}
|
||
recordFail := func() {
|
||
loginLim.recordFailure(limiterKey, config.C.Session.MaxFailures)
|
||
if ipKey != "" {
|
||
loginLim.recordFailure(ipKey, config.C.Session.IPMaxFailures)
|
||
}
|
||
}
|
||
if loginLim.locked(limiterKey) || (ipKey != "" && loginLim.locked(ipKey)) {
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "locked")
|
||
return nil, nil, ErrTooManyAttempts
|
||
}
|
||
|
||
var shop model.Shop
|
||
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
|
||
recordFail()
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "invalid_shop")
|
||
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 {
|
||
recordFail()
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "invalid_user")
|
||
return nil, nil, ErrInvalidCredentials
|
||
}
|
||
|
||
if !user.IsActive {
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "inactive")
|
||
return nil, nil, ErrUserInactive
|
||
}
|
||
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||
recordFail()
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "bad_password")
|
||
return nil, nil, ErrInvalidCredentials
|
||
}
|
||
|
||
if err := s.checkLicenseNotLocked(shop.ID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据
|
||
// 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT
|
||
// 的 lic_exp 带上新试用到期日。
|
||
s.ensureTrialOnFirstUse(shop.ID)
|
||
|
||
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
|
||
pclass := model.PlatformClass(dev.Platform)
|
||
quota := s.effectiveQuota(&shop, pclass)
|
||
if quota <= 0 {
|
||
s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed")
|
||
return nil, nil, ErrPlatformNotAllowed
|
||
}
|
||
|
||
sid := uuid.New().String()
|
||
jti := uuid.New().String() // 初始 refresh token jti,后续每次续期轮换
|
||
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,
|
||
RefreshJTI: jti,
|
||
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)
|
||
if ipKey != "" {
|
||
loginLim.reset(ipKey)
|
||
}
|
||
user.LastLoginAt = &now
|
||
|
||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return pair, &user, nil
|
||
}
|
||
|
||
// recordLoginAttempt 记录一次登录尝试(当前只用于失败审计),写库失败仅记日志不阻断登录。
|
||
func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceInfo, success bool, reason string) {
|
||
att := model.LoginAttempt{
|
||
ShopCode: shopCode,
|
||
Username: username,
|
||
IP: dev.IP,
|
||
UserAgent: dev.UserAgent,
|
||
Success: success,
|
||
Reason: reason,
|
||
}
|
||
if err := s.db.Create(&att).Error; err != nil {
|
||
log.Printf("[auth] record login attempt failed: %v", err)
|
||
}
|
||
}
|
||
|
||
// 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 隔离)。byUserID 记入 revoked_by 供审计。
|
||
func (s *AuthService) ForceLogout(shopID, sessionID, byUserID 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", "revoked_by": byUserID})
|
||
if res.Error != nil {
|
||
return res.Error
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return gorm.ErrRecordNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RevokeUserSessions 吊销某用户在本店的全部活跃会话(改密/禁用时调用)。byUserID 为操作人(记入 revoked_by)。
|
||
// db 入参以便 handler 在不持有 AuthService 时也能复用同一语义。
|
||
func RevokeUserSessions(db *gorm.DB, shopID, userID, byUserID uint64, reason string) error {
|
||
updates := map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": reason}
|
||
if byUserID != 0 {
|
||
updates["revoked_by"] = byUserID
|
||
}
|
||
return db.Model(&model.UserSession{}).
|
||
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL", shopID, userID).
|
||
Updates(updates).Error
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
// 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。
|
||
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 的会话:在事务内 FOR UPDATE 锁行做「校验 → 重用检测 → 轮换」(check-then-act)。
|
||
// 无 sid 的存量 token:首刷即建立可吊销会话并签发带 sid 的新 token,使其转入受控状态
|
||
//(自此可被强制下线/踢人/改密吊销),不再永久游离于会话治理之外。
|
||
newJTI := uuid.New().String()
|
||
now := time.Now()
|
||
sid := claims.SID
|
||
if sid == "" {
|
||
// 存量无 sid token:复用「该用户唯一的 legacy 会话」,而非每次续期都新建一条。
|
||
// 事务 + FOR UPDATE 锁住既有 legacy 行、串行化并发的存量续期;找不到才创建。
|
||
// 这样重复/并发呈递同一存量 token 始终只对应一条可吊销会话(有界、纳入会话治理、
|
||
// 可被强制下线/改密吊销),不再每刷一条无限膨胀。正常客户端首刷后即拿到带 sid 的新
|
||
// token,自然转入受控分支,此 legacy 行随其过期由清理任务回收。
|
||
exp := now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour)
|
||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
// 锁住该用户全部活跃 legacy 会话。legacy class 不在 effectiveQuota 的
|
||
// desktop/mobile/web 映射内(会返回 0),若不在此显式约束,legacy 会话便游离于
|
||
// 并发配额之外。这里把「每用户至多一条 legacy 会话」从 find-or-create 的隐式产物
|
||
// 提升为显式上限:复用最早的一条(轮换 jti),其余多余 legacy 会话一并吊销。
|
||
var sessions []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",
|
||
user.ShopID, user.ID, "legacy").
|
||
Order("id ASC").Find(&sessions).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(sessions) > 0 {
|
||
keep := sessions[0]
|
||
sid = keep.SID
|
||
if err := tx.Model(&model.UserSession{}).Where("id = ?", keep.ID).
|
||
Updates(map[string]interface{}{
|
||
"refresh_jti": newJTI,
|
||
"last_seen_at": now,
|
||
"refresh_exp_at": exp,
|
||
}).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(sessions) > 1 {
|
||
extraIDs := make([]uint64, 0, len(sessions)-1)
|
||
for _, ex := range sessions[1:] {
|
||
extraIDs = append(extraIDs, ex.ID)
|
||
}
|
||
if err := tx.Model(&model.UserSession{}).Where("id IN ?", extraIDs).
|
||
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
newSID := uuid.New().String()
|
||
sess := model.UserSession{
|
||
ShopID: user.ShopID,
|
||
UserID: user.ID,
|
||
SID: newSID,
|
||
Platform: "legacy",
|
||
PlatformClass: "legacy",
|
||
RefreshJTI: newJTI,
|
||
LastSeenAt: now,
|
||
RefreshExpAt: exp,
|
||
}
|
||
if err := tx.Create(&sess).Error; err != nil {
|
||
return err
|
||
}
|
||
sid = newSID
|
||
return nil
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
} else {
|
||
var reuseSessID uint64
|
||
txErr := s.db.Transaction(func(tx *gorm.DB) error {
|
||
var sess model.UserSession
|
||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||
Where("sid = ?", claims.SID).First(&sess).Error; err != nil {
|
||
return ErrSessionRevoked
|
||
}
|
||
if sess.RevokedAt != nil {
|
||
return ErrSessionRevoked
|
||
}
|
||
// 重用检测:会话已确立 jti,但呈递的 refresh token 携带的 jti 与当前值不符 →
|
||
// 说明这是已被轮换取代的旧 token 重放(盗用信号),吊销整条会话。
|
||
// 向后兼容:sess.RefreshJTI=="" 为存量会话,首刷直接采纳新 jti,不报重用。
|
||
// 注意:吊销动作放到事务外执行——此处一旦 return 非 nil 错误,事务会回滚,
|
||
// 在事务内写吊销会被一并回滚掉。
|
||
if sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI {
|
||
reuseSessID = sess.ID
|
||
return errRefreshReuse
|
||
}
|
||
// 轮换 jti + 刷新存活时间。
|
||
return tx.Model(&model.UserSession{}).Where("id = ?", sess.ID).
|
||
Updates(map[string]interface{}{"refresh_jti": newJTI, "last_seen_at": now}).Error
|
||
})
|
||
if errors.Is(txErr, errRefreshReuse) {
|
||
// 事务外提交吊销(不随回滚丢失),吊销整条会话。写库失败必须记日志——
|
||
// 否则盗用信号被静默吞掉,被取代的旧 token 仍可继续续期,功能形同虚设。
|
||
if err := s.db.Model(&model.UserSession{}).Where("id = ?", reuseSessID).
|
||
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "reuse"}).Error; err != nil {
|
||
log.Printf("[auth] revoke reused session %d failed: %v", reuseSessID, err)
|
||
}
|
||
return nil, ErrSessionRevoked
|
||
}
|
||
if txErr != nil {
|
||
return nil, txErr
|
||
}
|
||
}
|
||
|
||
// 自动续登(refresh)路径同样补发首次试用:老用户用本地 refresh token 自动登录、
|
||
// 从不走 Login(),否则永远停留在「未激活」。须在 issueTokens 之前,使新 JWT 的
|
||
// lic_exp 带上试用到期日。
|
||
s.ensureTrialOnFirstUse(user.ShopID)
|
||
|
||
return s.issueTokens(user.ID, user.ShopID, user.Role, sid, newJTI)
|
||
}
|
||
|
||
// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial,
|
||
// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。
|
||
// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。
|
||
// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。
|
||
func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) {
|
||
// 快路径:绝大多数登录/续期门店已有有效授权。先做一次无锁 Count 直接返回,
|
||
// 避免每次都开事务锁 shop 行——该函数在每次 Login/RefreshTokens 都被调用,
|
||
// 高频路径上的行锁会无谓串行化同店并发请求。
|
||
var count int64
|
||
if err := s.db.Model(&model.License{}).
|
||
Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil {
|
||
log.Printf("[license] auto-trial precheck for shop %d failed: %v", shopID, err)
|
||
return
|
||
}
|
||
if count > 0 {
|
||
return
|
||
}
|
||
|
||
// 慢路径(首次使用):事务内锁住门店行后**重新** Count,串行化同店并发的「首次试用」
|
||
// 判断:无既有 license 行可锁,故锁父级 shop 行,避免两个并发请求都读到 count==0
|
||
// 各发一条 trial(重复授权)。
|
||
issued := false
|
||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
var shop model.Shop
|
||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||
Where("id = ?", shopID).First(&shop).Error; err != nil {
|
||
return err
|
||
}
|
||
var c int64
|
||
if err := tx.Model(&model.License{}).
|
||
Where("shop_id = ? AND is_active = 1", shopID).Count(&c).Error; err != nil {
|
||
return err
|
||
}
|
||
if c > 0 {
|
||
return nil
|
||
}
|
||
if err := issueTrialLicense(tx, shopID); err != nil {
|
||
return err
|
||
}
|
||
issued = true
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err)
|
||
return
|
||
}
|
||
// 事务提交后再失效 phase 缓存:此刻新签发的 trial 行对其它连接已可见,
|
||
// 不会在提交前的窗口里被旧 phase 重新填充(修复缓存失效早于提交的竞态)。
|
||
if issued {
|
||
middleware.InvalidateLicensePhase(shopID)
|
||
}
|
||
}
|
||
|
||
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, refreshJTI 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{
|
||
ID: refreshJTI, // jti:refresh token 轮换与重用检测的依据
|
||
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
|
||
}
|