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

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