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