Files
jiang13-bbs/backend/service/login_device.go
freefire 31f5360c26 feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
2026-09-26 01:10:10 +08:00

221 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
const loginDeviceLimit = 20
var (
// ErrKickCurrentDevice 不能剔除自己正在用的会话
ErrKickCurrentDevice = errors.New("不能剔除当前正在使用的设备")
// ErrLoginDeviceNotFound 目标会话不存在、已过期或已吊销
ErrLoginDeviceNotFound = errors.New("该登录设备不存在或已失效")
)
// LoginDevice 用户安全设置里仍有效的登录会话(按设备指纹去重)
type LoginDevice struct {
ID uint `json:"id"`
IP string `json:"ip"`
DeviceKind string `json:"device_kind"`
DeviceLabel string `json:"device_label"`
Browser string `json:"browser"`
Current bool `json:"current"`
CreatedAt time.Time `json:"created_at"`
}
func sessionFamilyID(rt model.RefreshToken) uint {
if rt.FamilyID != 0 {
return rt.FamilyID
}
return rt.ID
}
func tokenLoggedInAt(rt model.RefreshToken) time.Time {
if !rt.LoggedInAt.IsZero() {
return rt.LoggedInAt
}
return rt.CreatedAt
}
func toLoginDevice(rt model.RefreshToken, current bool) LoginDevice {
ua := ParseUserAgent(rt.UserAgent)
ip := canonicalIP(rt.IP)
return LoginDevice{
ID: rt.ID,
IP: ip,
DeviceKind: ua.Kind,
DeviceLabel: ua.Label,
Browser: ua.Browser,
Current: current,
CreatedAt: tokenLoggedInAt(rt),
}
}
func pickCurrentSession(tokens []model.RefreshToken, currentFamily uint, reqFP string) *model.RefreshToken {
if currentFamily != 0 {
for i := range tokens {
if sessionFamilyID(tokens[i]) == currentFamily {
return &tokens[i]
}
}
}
for i := range tokens {
if deviceFingerprint(tokens[i].IP, tokens[i].UserAgent) == reqFP {
return &tokens[i]
}
}
return nil
}
// ListLoginDevices 列出仍有效的登录会话,同一设备(归一化 IP + UA)只保留最近一条。
func (s *AuthService) ListLoginDevices(userID, currentFamily uint, clientIP, userAgent string) ([]LoginDevice, error) {
now := time.Now()
var tokens []model.RefreshToken
if err := s.db.Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Order("last_used_at DESC, created_at DESC, id DESC").
Find(&tokens).Error; err != nil {
return nil, err
}
reqFP := deviceFingerprint(clientIP, userAgent)
currentRT := pickCurrentSession(tokens, currentFamily, reqFP)
currentFP := ""
out := make([]LoginDevice, 0, 8)
if currentRT != nil {
currentFP = deviceFingerprint(currentRT.IP, currentRT.UserAgent)
out = append(out, toLoginDevice(*currentRT, true))
}
seen := make(map[string]struct{}, len(tokens))
if currentFP != "" {
seen[currentFP] = struct{}{}
}
for _, rt := range tokens {
fp := deviceFingerprint(rt.IP, rt.UserAgent)
if _, ok := seen[fp]; ok {
continue
}
seen[fp] = struct{}{}
out = append(out, toLoginDevice(rt, false))
if len(out) >= loginDeviceLimit {
break
}
}
return out, nil
}
// RevokeLoginDevice 剔除其它设备:吊销该设备指纹下除当前会话外的全部有效 refresh token。
func (s *AuthService) RevokeLoginDevice(userID, currentFamily, deviceID uint) error {
now := time.Now()
var target model.RefreshToken
if err := s.db.Where("id = ? AND user_id = ? AND revoked = ? AND expires_at > ?",
deviceID, userID, false, now).First(&target).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrLoginDeviceNotFound
}
return err
}
if currentFamily != 0 && sessionFamilyID(target) == currentFamily {
return ErrKickCurrentDevice
}
fp := deviceFingerprint(target.IP, target.UserAgent)
var actives []model.RefreshToken
if err := s.db.Select("id", "family_id", "ip", "user_agent").
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Find(&actives).Error; err != nil {
return err
}
ids := make([]uint, 0, len(actives))
for _, t := range actives {
if currentFamily != 0 && sessionFamilyID(t) == currentFamily {
continue
}
if deviceFingerprint(t.IP, t.UserAgent) == fp {
ids = append(ids, t.ID)
}
}
if len(ids) == 0 {
return ErrKickCurrentDevice
}
return s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error
}
func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint {
if plain == "" {
return 0
}
var rt model.RefreshToken
if err := s.db.Select("id", "user_id", "family_id", "revoked", "expires_at").
Where("token_hash = ?", hashRefreshToken(plain)).First(&rt).Error; err != nil {
return 0
}
if rt.UserID != userID || rt.Revoked || time.Now().After(rt.ExpiresAt) {
return 0
}
return sessionFamilyID(rt)
}
// TouchDeviceFromRefresh 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。
func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) uint {
if plain == "" {
return 0
}
var rt model.RefreshToken
if err := s.db.Where("token_hash = ?", hashRefreshToken(plain)).First(&rt).Error; err != nil {
return 0
}
if rt.UserID != userID || rt.Revoked || time.Now().After(rt.ExpiresAt) {
return 0
}
now := time.Now()
upd := map[string]any{"last_used_at": now, "updated_at": now}
if v := truncateStr(ip, 45); v != "" {
upd["ip"] = v
}
if v := truncateStr(ua, 500); v != "" {
upd["user_agent"] = v
}
fid := sessionFamilyID(rt)
if rt.FamilyID == 0 {
upd["family_id"] = fid
}
_ = s.db.Model(&rt).Updates(upd).Error
return fid
}
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
now := time.Now()
var actives []model.RefreshToken
if err := s.db.Select("id", "family_id").
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Find(&actives).Error; err != nil {
return 0
}
ids := make([]uint, 0)
for _, t := range actives {
if sessionFamilyID(t) == keepFamily {
continue
}
ids = append(ids, t.ID)
}
if len(ids) == 0 {
return 0
}
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
if res.Error != nil {
return 0
}
return int(res.RowsAffected)
}