diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md index 93b83a7..7f13e8f 100644 --- a/.cursor/project_memory.md +++ b/.cursor/project_memory.md @@ -72,6 +72,7 @@ - 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible) - 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400 - 登录历史 login_logs:成功失败都记录(失败用户名可不存在 user_id=0);GET /api/admin/users/:id/login-logs;用户列表有 online(last_seen_at 5 分钟内绿点)/last_login_ip/last_login_at 列 +- 用户安全设置「最近登录设备」:GET /api/me/login-devices(RequireAuth,仅本人仍有效的 refresh 会话,按归一化 IP+UA 指纹去重);DELETE /api/me/login-devices/:id 剔除其它设备(吊销该指纹下除当前会话外的 refresh,JWT `fid` 使 access 立即失效,需重新输入密码)。展示设备/浏览器与原始 IP(不使用 ip2region);本机标 current=当前在线,无问号。SSR 转发 refresh cookie + 浏览器 UA/IP,直出 `/u/:id?tab=security` - 角色变更事务内 invalidateUserSessions(token_version+1 + 撤销 refresh tokens),仅板块授权变化不下线;board_admin 授权必须 ≥1 个有效板块否则 400 - 前端:lib/roles.ts(ROLE_META:owner=Crown/--gold,super=BadgeCheck,admin=ShieldCheck,board=Shield,user=Users;lucide 无 ShieldStar)、RoleBadge 组件(truncate 用于窄列+板块名);/admin/moderation 审核队列页(待审角标 WS `moderation:changed` + 回前台 HTTP 校准,无定时轮询);Forbidden.tsx 子页守卫视图(HTTP 仍 200);用户管理 /admin/users 首列收窄 grid-cols-[minmax(0,1fr)_78px_...] - 新用户发帖有 24h 冷静期(注册接口无此限制,API 回归需 SQL 回退 created_at);CreatePostRequest.tags 是字符串不是数组 diff --git a/backend/go.mod b/backend/go.mod index 6578095..a177175 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,7 +6,9 @@ require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/gorilla/websocket v1.5.3 golang.org/x/crypto v0.43.0 + golang.org/x/image v0.46.0 gopkg.in/ini.v1 v1.67.0 gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.12 @@ -23,7 +25,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -41,7 +42,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/image v0.46.0 // indirect golang.org/x/net v0.45.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 64f0023..7d3fc6f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -97,18 +97,12 @@ golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 1719b9a..d8ee56a 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -3,6 +3,8 @@ package handler import ( "errors" "net/http" + "strconv" + "strings" "time" "github.com/freefire/jiang13-bbs/middleware" @@ -115,7 +117,7 @@ func (h *Handlers) Register(c *gin.Context) { } _ = h.Chat.EnsureDefaultMembership(user.ID) // 自动登录:走与 Login 相同的 token 签发链路 - accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password) + accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) if err != nil { // 签发失败不应让注册白做:仍返回成功,让前端引导去登录页 c.JSON(http.StatusOK, gin.H{ @@ -144,7 +146,7 @@ func (h *Handlers) Login(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password) + accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) if err != nil { // 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0 h.AdminUser.RecordLogin( @@ -181,7 +183,7 @@ func (h *Handlers) Refresh(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } - accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken) + accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent()) if err != nil { clearAuthCookies(c, !h.Cfg.DevMode) if errors.Is(err, service.ErrAccountBanned) { @@ -238,6 +240,60 @@ func (h *Handlers) ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"}) } +// MyLoginDevices 当前用户仍有效的登录设备(仅本人;本机标当前在线) +func (h *Handlers) MyLoginDevices(c *gin.Context) { + claims := middleware.CurrentUser(c) + ua := strings.TrimSpace(c.GetHeader("X-J13-Client-UA")) + if ua == "" { + ua = c.Request.UserAgent() + } + ip := strings.TrimSpace(c.GetHeader("X-J13-Client-IP")) + if ip == "" { + ip = c.ClientIP() + } + familyID := claims.FamilyID + if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil { + if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 { + familyID = fid + } + } + devices, err := h.Auth.ListLoginDevices(claims.ID, familyID, ip, ua) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取登录设备失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"devices": nonNilSlice(devices)}) +} + +// RevokeLoginDevice 剔除其它设备,该设备需重新输入密码登录 +func (h *Handlers) RevokeLoginDevice(c *gin.Context) { + claims := middleware.CurrentUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "设备 ID 无效"}) + return + } + familyID := claims.FamilyID + if refreshToken, e := c.Cookie(service.RefreshCookieName); e == nil { + if fid := h.Auth.LookupFamilyByRefreshPlain(refreshToken, claims.ID); fid != 0 { + familyID = fid + } + } + if err := h.Auth.RevokeLoginDevice(claims.ID, familyID, uint(id)); err != nil { + if errors.Is(err, service.ErrKickCurrentDevice) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, service.ErrLoginDeviceNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + // meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块, // 供前端渲染管理菜单与前台审核按钮;其他角色为空数组 func meUserBody(user *model.User, boardIDs []uint) gin.H { diff --git a/backend/model/db.go b/backend/model/db.go index 800b13f..8525c59 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -42,6 +42,11 @@ func InitDB(dsn string) error { return fmt.Errorf("公告置顶列迁移失败: %w", err) } + // refresh_tokens 会话时间列:存量行不能直接 ADD NOT NULL + if err := prepareRefreshTokenSessionColumns(db); err != nil { + return fmt.Errorf("refresh token 会话列迁移失败: %w", err) + } + if err := db.AutoMigrate( &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{}, @@ -152,6 +157,37 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error { return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error } +// prepareRefreshTokenSessionColumns 存量 refresh_tokens 加 logged_in_at / last_used_at: +// 先可空加列,用 created_at 回填后再收紧 NOT NULL。 +func prepareRefreshTokenSessionColumns(db *gorm.DB) error { + var tableCount int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'refresh_tokens'`). + Scan(&tableCount).Error; err != nil { + return err + } + if tableCount == 0 { + return nil + } + for _, col := range []string{"logged_in_at", "last_used_at"} { + if err := db.Exec(fmt.Sprintf( + `ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS %s timestamptz`, col, + )).Error; err != nil { + return err + } + if err := db.Exec(fmt.Sprintf( + `UPDATE refresh_tokens SET %s = created_at WHERE %s IS NULL`, col, col, + )).Error; err != nil { + return err + } + if err := db.Exec(fmt.Sprintf( + `ALTER TABLE refresh_tokens ALTER COLUMN %s SET NOT NULL`, col, + )).Error; err != nil { + return err + } + } + return nil +} + // prepareRefreshTokenMigration 旧版 refresh_tokens 表把明文存在 token 列, // 新版改为 token_hash(SHA-256,NOT NULL+唯一索引)。在 AutoMigrate 之前: // 1. 新增可带默认值的 token_hash 列(避免对存量行加 NOT NULL 列失败) diff --git a/backend/model/models.go b/backend/model/models.go index e23f72d..d51ff4a 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -137,8 +137,13 @@ type User struct { type RefreshToken struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"index;not null" json:"user_id"` + FamilyID uint `gorm:"index;not null;default:0" json:"-"` // 同一登录会话跨轮转保持不变 TokenHash string `gorm:"uniqueIndex;size:64;not null" json:"-"` TokenCipher string `gorm:"size:512;default:''" json:"-"` + IP string `gorm:"size:45;not null;default:''" json:"-"` + UserAgent string `gorm:"size:512;not null;default:''" json:"-"` + LoggedInAt time.Time `gorm:"not null" json:"-"` // 该会话首次登录时间,轮转时原样复制 + LastUsedAt time.Time `gorm:"index;not null" json:"-"` ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"` Revoked bool `gorm:"default:false;index" json:"revoked"` RotatedTo uint `gorm:"index;default:0" json:"-"` diff --git a/backend/router/router.go b/backend/router/router.go index b67c8a4..fe53b7f 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -147,6 +147,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api := r.Group("/api", authMW.RequireAuth(), middleware.CSRFMiddleware()) { api.POST("/change-password", h.ChangePassword) + api.GET("/me/login-devices", h.MyLoginDevices) + api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice) api.PUT("/profile", h.UpdateProfile) api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost) api.PUT("/posts/:id", h.UpdatePost) diff --git a/backend/service/auth.go b/backend/service/auth.go index 0e8b3a4..90b72df 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -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 } diff --git a/backend/service/login_device.go b/backend/service/login_device.go new file mode 100644 index 0000000..5c23820 --- /dev/null +++ b/backend/service/login_device.go @@ -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}) +} diff --git a/backend/service/ua.go b/backend/service/ua.go new file mode 100644 index 0000000..ef2d82f --- /dev/null +++ b/backend/service/ua.go @@ -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)) +} diff --git a/backend/service/ua_test.go b/backend/service/ua_test.go new file mode 100644 index 0000000..3dc2804 --- /dev/null +++ b/backend/service/ua_test.go @@ -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") + } +} diff --git a/frontend/app/u/[id]/LoginDevices.tsx b/frontend/app/u/[id]/LoginDevices.tsx new file mode 100644 index 0000000..d2952c2 --- /dev/null +++ b/frontend/app/u/[id]/LoginDevices.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Monitor, Smartphone, Tablet } from "lucide-react"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import { apiRevokeLoginDevice, type LoginDevice } from "@/lib/api"; +import { formatDateTime } from "@/lib/format"; + +function DeviceIcon({ kind }: { kind: LoginDevice["device_kind"] }) { + const props = { size: 20, strokeWidth: 1.75, "aria-hidden": true as const }; + if (kind === "mobile") return ; + if (kind === "tablet") return ; + return ; +} + +/** + * 安全设置:仍有效的登录会话。 + * 本机标「当前在线」;其它设备可剔除,对方需重新输入密码登录。 + */ +export default function LoginDevices({ devices: initial }: { devices: LoginDevice[] }) { + const router = useRouter(); + const [devices, setDevices] = useState(initial); + const [target, setTarget] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + setDevices(initial); + }, [initial]); + + async function confirmKick() { + if (!target) return; + setBusy(true); + setError(""); + try { + const res = await apiRevokeLoginDevice(target.id); + if (res.error) { + setError(res.error); + return; + } + setDevices((prev) => prev.filter((d) => d.id !== target.id)); + setTarget(null); + router.refresh(); + } catch { + setError("网络异常,请稍后重试"); + } finally { + setBusy(false); + } + } + + return ( + + + + 最近登录的设备 + + + 下列为仍有效的登录会话。若出现无法识别的设备,请立即剔除或修改密码 + + + + {devices.length === 0 ? ( + + + 暂无登录记录 + + ) : ( + + {devices.map((d, i) => { + const ip = d.ip || "未知 IP"; + return ( + + + + + + + {d.device_label} + + + IP {ip} · {d.browser} + + + + + {formatDateTime(d.created_at)} + + {d.current ? ( + + 当前在线 + + ) : ( + { + setError(""); + setTarget(d); + }} + > + 剔除 + + )} + + + ); + })} + + )} + + + void confirmKick()} + onCancel={() => { + if (!busy) { + setTarget(null); + setError(""); + } + }} + /> + + ); +} diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index e057dc0..01c70ac 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -1,16 +1,20 @@ import Link from "next/link"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { Metadata } from "next"; import { ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield, Settings, KeyRound, Info, PenLine, } from "lucide-react"; -import { fetchUserProfile, fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api"; -import { authCookieHeader } from "@/lib/cookies"; +import { + fetchUserProfile, fetchUserComments, fetchLoginDevices, + type UserProfile, type UserCommentsResponse, type LoginDevice, +} from "@/lib/api"; +import { authCookieHeader, sessionCookieHeader } from "@/lib/cookies"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import UserProfileContent from "./UserProfileContent"; import ProfileForm from "./ProfileForm"; import PasswordForm from "./PasswordForm"; +import LoginDevices from "./LoginDevices"; import ProfileHeroAvatar from "./ProfileHeroAvatar"; import RoleBadge from "@/components/RoleBadge"; import { roleMeta } from "@/lib/roles"; @@ -96,6 +100,24 @@ export default async function UserPage({ params, searchParams }: UserPageProps) } } + // 安全设置:仍有效的登录会话(仅本人) + // SSR 直连后端时必须转发 refresh cookie + 浏览器 UA/IP,才能标「当前在线」并避免把 Node fetch 当成一台设备 + let loginDevices: LoginDevice[] = []; + if (effectiveTab === "security" && isOwner) { + try { + const hdrs = await headers(); + const sessionCookie = sessionCookieHeader(await cookies()); + loginDevices = await fetchLoginDevices(sessionCookie || cookie || undefined, { + userAgent: hdrs.get("user-agent") || undefined, + clientIP: hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() + || hdrs.get("x-real-ip") + || undefined, + }); + } catch { + loginDevices = []; + } + } + const u = data.user; const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN"); const showPoints = data.stats.points > 0 || isOwner; @@ -336,6 +358,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps) 修改成功后将在所有设备登出,需要使用新密码重新登录 + ) : null} @@ -434,6 +457,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps) · 邮箱仅你自己可见,用于安全通知。 + {effectiveTab === "security" && ( + + · + 「当前在线」是你正在使用的这台设备。其它记录可剔除,对方需重新输入密码才能登录。 + + )} )} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index c31a13a..b1ab7ed 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1346,6 +1346,47 @@ export async function apiChangePassword(oldPassword: string, newPassword: string return res.json(); } +/** 当前用户仍有效的登录会话(安全设置 SSR / 客户端) */ +export interface LoginDevice { + id: number; + ip: string; + device_kind: "computer" | "mobile" | "tablet"; + device_label: string; + browser: string; + current: boolean; + created_at: string; +} + +export interface LoginDevicesResponse { + devices: LoginDevice[]; +} + +export async function fetchLoginDevices( + cookieHeader?: string, + viewer?: { userAgent?: string; clientIP?: string } +): Promise { + const headers: Record = {}; + if (cookieHeader) headers.Cookie = cookieHeader; + if (viewer?.userAgent) headers["X-J13-Client-UA"] = viewer.userAgent; + if (viewer?.clientIP) headers["X-J13-Client-IP"] = viewer.clientIP; + const res = await fetch(`${API_BASE}/api/me/login-devices`, ssrInit(headers)); + if (res.status === 401) return []; + if (!res.ok) throw new Error("获取登录设备失败"); + const data = (await res.json()) as LoginDevicesResponse; + return data.devices ?? []; +} + +/** 剔除其它登录设备,该设备需重新输入密码登录 */ +export async function apiRevokeLoginDevice( + id: number +): Promise<{ ok?: boolean; error?: string }> { + const res = await fetchWithRefresh(`/api/me/login-devices/${id}`, { + method: "DELETE", + headers: clientHeaders(), + }); + return res.json(); +} + // 更新当前用户资料(昵称、头像、邮箱、签名) export async function apiUpdateProfile(data: { nickname: string; diff --git a/frontend/lib/cookies.ts b/frontend/lib/cookies.ts index 9397ce8..2d6cda1 100644 --- a/frontend/lib/cookies.ts +++ b/frontend/lib/cookies.ts @@ -26,6 +26,19 @@ export function authCookieHeader(store: CookieReader): string { return token ? `${TOKEN_COOKIE}=${token.value}` : ""; } +/** + * 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。 + * 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。 + */ +export function sessionCookieHeader(store: CookieReader): string { + const parts: string[] = []; + const token = store.get(TOKEN_COOKIE); + if (token) parts.push(`${TOKEN_COOKIE}=${token.value}`); + const refresh = store.get(REFRESH_COOKIE); + if (refresh) parts.push(`${REFRESH_COOKIE}=${refresh.value}`); + return parts.join("; "); +} + /** * 帖子详情 SSR:access token + 密码隐藏块解锁 cookie。 * 解锁 cookie 为 HttpOnly,必须随 SSR 转发,否则 refresh 后密码块会再次锁上。 diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts index 3ac9600..ba20453 100644 --- a/frontend/lib/format.ts +++ b/frontend/lib/format.ts @@ -47,3 +47,10 @@ export function formatDate(dateStr: string): string { const d = new Date(dateStr); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } + +/** 完整日期时间 YYYY-MM-DD HH:mm:ss(本地时区) */ +export function formatDateTime(dateStr: string): string { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return ""; + return `${formatDate(dateStr)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; +}
+ 下列为仍有效的登录会话。若出现无法识别的设备,请立即剔除或修改密码 +
暂无登录记录
+ {d.device_label} +
+ IP {ip} · {d.browser} +
+ {formatDateTime(d.created_at)} +
+ 当前在线 +
修改成功后将在所有设备登出,需要使用新密码重新登录