feat: 安全设置展示有效登录会话,支持剔除其它设备

按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 01:48:52 +08:00
parent 2ccfb5f330
commit 3a7bfe59df
16 changed files with 808 additions and 28 deletions

View File

@@ -63,7 +63,8 @@ type UserClaims struct {
Username string `json:"username"`
Role string `json:"role"`
Banned bool `json:"banned"`
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
FamilyID uint `json:"fid"` // 登录会话家族,剔除设备时使该会话 JWT 立即失效
}
// AuthService 认证服务
@@ -111,7 +112,7 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
}
// Login 用户登录,返回 access token + refresh token + user
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
@@ -126,11 +127,11 @@ func (s *AuthService) Login(username, password string) (string, string, *model.U
return "", "", nil, ErrAccountBanned
}
accessToken, err := s.generateToken(&user)
refreshToken, familyID, err := s.CreateRefreshToken(user.ID, ip, ua)
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.CreateRefreshToken(user.ID)
accessToken, err := s.generateToken(&user, familyID)
if err != nil {
return "", "", nil, err
}
@@ -138,7 +139,7 @@ func (s *AuthService) Login(username, password string) (string, string, *model.U
}
// generateToken 签发 access token(短期)
func (s *AuthService) generateToken(user *model.User) (string, error) {
func (s *AuthService) generateToken(user *model.User, familyID uint) (string, error) {
claims := &tokenClaims{
UserClaims: UserClaims{
ID: user.ID,
@@ -146,6 +147,7 @@ func (s *AuthService) generateToken(user *model.User) (string, error) {
Role: string(user.Role),
Banned: user.Banned,
TokenVersion: user.TokenVersion,
FamilyID: familyID,
},
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.Username,
@@ -190,9 +192,22 @@ func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
if user.TokenVersion != claims.TokenVersion {
return nil, ErrTokenRevoked
}
// 单设备剔除:该会话家族已无有效 refresh token 则立即失效
if claims.FamilyID != 0 && !s.familySessionActive(user.ID, claims.FamilyID) {
return nil, ErrTokenRevoked
}
return &user, nil
}
func (s *AuthService) familySessionActive(userID, familyID uint) bool {
var n int64
s.db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (family_id = ? OR (family_id = 0 AND id = ?))",
userID, false, time.Now(), familyID, familyID).
Count(&n)
return n > 0
}
// GetUserByID 根据 ID 获取用户
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
var user model.User
@@ -279,23 +294,33 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
return string(plain), nil
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文)
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
if err != nil {
return "", err
return "", 0, err
}
now := time.Now()
rt := &model.RefreshToken{
UserID: userID,
TokenHash: hashRefreshToken(token),
TokenCipher: cipherText,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
IP: truncateStr(ip, 45),
UserAgent: truncateStr(ua, 500),
LoggedInAt: now,
LastUsedAt: now,
ExpiresAt: now.Add(RefreshTokenTTL),
}
if err := s.db.Create(rt).Error; err != nil {
return "", err
return "", 0, err
}
return token, nil
if err := s.db.Model(rt).Update("family_id", rt.ID).Error; err != nil {
return "", 0, err
}
rt.FamilyID = rt.ID
s.revokeSameFingerprintExcept(userID, rt.FamilyID, ip, ua)
return token, rt.FamilyID, nil
}
// loadActiveUser 加载用户并实时校验封禁状态
@@ -336,7 +361,7 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
var (
accessToken, newRefresh string
outUser *model.User
@@ -368,7 +393,7 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
next.TokenCipher != "" {
if sameRefresh, e := s.decryptToken(next.TokenCipher); e == nil {
if at, e := s.generateToken(user); e == nil {
if at, e := s.generateToken(user, sessionFamilyID(next)); e == nil {
accessToken, newRefresh, outUser = at, sameRefresh, user
return nil
}
@@ -392,10 +417,24 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
if err != nil {
return err
}
familyID := sessionFamilyID(rt)
ipOut := truncateStr(ip, 45)
if ipOut == "" {
ipOut = rt.IP
}
uaOut := truncateStr(ua, 500)
if uaOut == "" {
uaOut = rt.UserAgent
}
nr := &model.RefreshToken{
UserID: rt.UserID,
FamilyID: familyID,
TokenHash: hashRefreshToken(plain),
TokenCipher: cipherText,
IP: ipOut,
UserAgent: uaOut,
LoggedInAt: tokenLoggedInAt(rt),
LastUsedAt: now,
ExpiresAt: now.Add(RefreshTokenTTL),
}
if err := tx.Create(nr).Error; err != nil {
@@ -411,7 +450,7 @@ func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *mode
}).Error; err != nil {
return err
}
at, err := s.generateToken(user)
at, err := s.generateToken(user, familyID)
if err != nil {
return err
}

View 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})
}

122
backend/service/ua.go Normal file
View File

@@ -0,0 +1,122 @@
package service
import (
"net/netip"
"strings"
)
// DeviceKind 设备形态(前端据此选图标)
const (
DeviceKindComputer = "computer"
DeviceKindMobile = "mobile"
DeviceKindTablet = "tablet"
)
// ParsedUA 从 User-Agent 抽出的可读设备信息
type ParsedUA struct {
Kind string // computer | mobile | tablet
OS string // Windows / macOS / Android / iOS / Linux
Label string // Windows 电脑 / Android 手机
Browser string
}
// ParseUserAgent 轻量 UA 解析,覆盖常见桌面/移动浏览器,不引入第三方库。
func ParseUserAgent(ua string) ParsedUA {
u := strings.TrimSpace(ua)
if u == "" {
return ParsedUA{Kind: DeviceKindComputer, OS: "未知", Label: "未知设备", Browser: "未知浏览器"}
}
low := strings.ToLower(u)
kind, osName, label := classifyDevice(u, low)
browser := classifyBrowser(u, low)
return ParsedUA{Kind: kind, OS: osName, Label: label, Browser: browser}
}
func classifyDevice(u, low string) (kind, osName, label string) {
hasAndroid := strings.Contains(low, "android")
hasIPhone := strings.Contains(low, "iphone")
hasIPad := strings.Contains(low, "ipad") || (strings.Contains(low, "macintosh") && strings.Contains(low, "mobile"))
hasMobileToken := strings.Contains(low, "mobile") || strings.Contains(u, "Mobile")
switch {
case hasIPad:
return DeviceKindTablet, "iOS", "iPad"
case hasIPhone:
return DeviceKindMobile, "iOS", "iOS 手机"
case hasAndroid:
if strings.Contains(low, "tablet") || !hasMobileToken {
return DeviceKindTablet, "Android", "Android 平板"
}
return DeviceKindMobile, "Android", "Android 手机"
case strings.Contains(low, "windows"):
return DeviceKindComputer, "Windows", "Windows 电脑"
case strings.Contains(low, "mac os") || strings.Contains(low, "macintosh"):
return DeviceKindComputer, "macOS", "macOS 电脑"
case strings.Contains(low, "cros"):
return DeviceKindComputer, "ChromeOS", "ChromeOS 电脑"
case strings.Contains(low, "linux"):
return DeviceKindComputer, "Linux", "Linux 电脑"
default:
if hasMobileToken {
return DeviceKindMobile, "未知", "手机"
}
return DeviceKindComputer, "未知", "电脑"
}
}
func classifyBrowser(u, low string) string {
switch {
case strings.Contains(u, "MicroMessenger"):
return "微信"
case strings.Contains(low, "edg/") || strings.Contains(low, "edgios/") || strings.Contains(low, "edga/"):
return "Microsoft Edge"
case strings.Contains(low, "opr/") || strings.Contains(low, "opera"):
return "Opera"
case strings.Contains(low, "firefox/") || strings.Contains(low, "fxios/"):
return "Firefox"
case strings.Contains(low, "crios/"):
return "Google Chrome"
case strings.Contains(low, "chrome/") && !strings.Contains(low, "edg"):
return "Google Chrome"
case strings.Contains(low, "safari/") && !strings.Contains(low, "chrome") && !strings.Contains(low, "chromium"):
return "Safari"
default:
return "未知浏览器"
}
}
func uaFingerprint(p ParsedUA) string {
return p.Kind + "|" + p.OS + "|" + p.Browser
}
func parseIPAddr(ip string) (netip.Addr, bool) {
ip = strings.TrimSpace(ip)
if ip == "" {
return netip.Addr{}, false
}
if i := strings.IndexByte(ip, '%'); i >= 0 {
ip = ip[:i]
}
addr, err := netip.ParseAddr(ip)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
// canonicalIP 归一化 IP:IPv4-mapped 解开;本机 ::1 / 127.0.0.1 视为同一地址。
func canonicalIP(ip string) string {
addr, ok := parseIPAddr(ip)
if !ok {
return strings.TrimSpace(ip)
}
if addr.IsLoopback() {
return "127.0.0.1"
}
return addr.String()
}
func deviceFingerprint(ip, ua string) string {
return canonicalIP(ip) + "|" + uaFingerprint(ParseUserAgent(ua))
}

View File

@@ -0,0 +1,75 @@
package service
import "testing"
func TestParseUserAgentChromeWindows(t *testing.T) {
ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
p := ParseUserAgent(ua)
if p.Kind != DeviceKindComputer || p.Label != "Windows 电脑" || p.Browser != "Google Chrome" {
t.Fatalf("got %+v", p)
}
}
func TestParseUserAgentEdgeWindows(t *testing.T) {
ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0"
p := ParseUserAgent(ua)
if p.Browser != "Microsoft Edge" || p.Label != "Windows 电脑" {
t.Fatalf("got %+v", p)
}
}
func TestParseUserAgentAndroidChrome(t *testing.T) {
ua := "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36"
p := ParseUserAgent(ua)
if p.Kind != DeviceKindMobile || p.Label != "Android 手机" || p.Browser != "Google Chrome" {
t.Fatalf("got %+v", p)
}
}
func TestParseUserAgentIPhoneSafari(t *testing.T) {
ua := "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
p := ParseUserAgent(ua)
if p.Kind != DeviceKindMobile || p.Label != "iOS 手机" || p.Browser != "Safari" {
t.Fatalf("got %+v", p)
}
}
func TestParseUserAgentWeChat(t *testing.T) {
ua := "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.50"
p := ParseUserAgent(ua)
if p.Browser != "微信" || p.Kind != DeviceKindMobile {
t.Fatalf("got %+v", p)
}
}
func TestParseUserAgentEmpty(t *testing.T) {
p := ParseUserAgent("")
if p.Label != "未知设备" {
t.Fatalf("got %+v", p)
}
}
func TestCanonicalIPLoopback(t *testing.T) {
if got := canonicalIP("::ffff:127.0.0.1"); got != "127.0.0.1" {
t.Fatalf("mapped got %q", got)
}
if got := canonicalIP("::1"); got != "127.0.0.1" {
t.Fatalf("::1 got %q", got)
}
if got := canonicalIP("127.0.0.1"); got != "127.0.0.1" {
t.Fatalf("v4 got %q", got)
}
}
func TestDeviceFingerprintMergesLoopback(t *testing.T) {
ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
a := deviceFingerprint("::1", ua)
b := deviceFingerprint("127.0.0.1", ua)
if a != b {
t.Fatalf("loopback fingerprint mismatch %q vs %q", a, b)
}
empty := deviceFingerprint("127.0.0.1", "")
if empty == a {
t.Fatalf("empty UA should not match Chrome")
}
}