feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
217
backend/service/login_device.go
Normal file
217
backend/service/login_device.go
Normal file
@@ -0,0 +1,217 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user