Files
jiang13-bbs/backend/service/login_device.go
freefire 3a7bfe59df feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 01:48:52 +08:00

218 lines
6.1 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
}
func (s *AuthService) revokeSameFingerprintExcept(userID, keepFamily uint, ip, ua string) {
fp := deviceFingerprint(ip, ua)
now := time.Now()
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
}
ids := make([]uint, 0)
for _, t := range actives {
if sessionFamilyID(t) == keepFamily {
continue
}
if deviceFingerprint(t.IP, t.UserAgent) == fp {
ids = append(ids, t.ID)
}
}
if len(ids) == 0 {
return
}
s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
}