feat: 安全设置展示有效登录会话,支持剔除其它设备
按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 是字符串不是数组
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 列失败)
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -64,6 +64,7 @@ type UserClaims struct {
|
||||
Role string `json:"role"`
|
||||
Banned bool `json:"banned"`
|
||||
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
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
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))
|
||||
}
|
||||
75
backend/service/ua_test.go
Normal file
75
backend/service/ua_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
143
frontend/app/u/[id]/LoginDevices.tsx
Normal file
143
frontend/app/u/[id]/LoginDevices.tsx
Normal file
@@ -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 <Smartphone {...props} />;
|
||||
if (kind === "tablet") return <Tablet {...props} />;
|
||||
return <Monitor {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全设置:仍有效的登录会话。
|
||||
* 本机标「当前在线」;其它设备可剔除,对方需重新输入密码登录。
|
||||
*/
|
||||
export default function LoginDevices({ devices: initial }: { devices: LoginDevice[] }) {
|
||||
const router = useRouter();
|
||||
const [devices, setDevices] = useState(initial);
|
||||
const [target, setTarget] = useState<LoginDevice | null>(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 (
|
||||
<div className="mt-8">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-[18px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
最近登录的设备
|
||||
</h2>
|
||||
<p className="meta mt-1 text-[13px]">
|
||||
下列为仍有效的登录会话。若出现无法识别的设备,请立即剔除或修改密码
|
||||
</p>
|
||||
</div>
|
||||
<div className="panel overflow-hidden">
|
||||
{devices.length === 0 ? (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<Monitor size={22} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
|
||||
<p className="meta text-[12.5px]">暂无登录记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{devices.map((d, i) => {
|
||||
const ip = d.ip || "未知 IP";
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
|
||||
style={{ borderTop: i === 0 ? undefined : "1px solid var(--line)" }}
|
||||
>
|
||||
<span
|
||||
className="mt-0.5 w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--tint)", color: "var(--ink-2)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<DeviceIcon kind={d.device_kind} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-semibold leading-snug truncate" style={{ color: "var(--ink)" }}>
|
||||
{d.device_label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 truncate tabular-nums">
|
||||
IP {ip} · {d.browser}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right pl-2">
|
||||
<p className="text-[12px] tabular-nums leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
{formatDateTime(d.created_at)}
|
||||
</p>
|
||||
{d.current ? (
|
||||
<p className="mt-1 text-[12px] font-semibold" style={{ color: "var(--ok)" }}>
|
||||
当前在线
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-[12px] font-semibold hover:underline"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setTarget(d);
|
||||
}}
|
||||
>
|
||||
剔除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!target}
|
||||
title="剔除这台设备?"
|
||||
message={
|
||||
error
|
||||
? `剔除失败:${error}`
|
||||
: "剔除后,该设备上的登录会立即失效,需要重新输入密码才能登录。"
|
||||
}
|
||||
confirmLabel="确认剔除"
|
||||
danger
|
||||
busy={busy}
|
||||
onConfirm={() => void confirmKick()}
|
||||
onCancel={() => {
|
||||
if (!busy) {
|
||||
setTarget(null);
|
||||
setError("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
<p className="meta mt-1 text-[13px]">修改成功后将在所有设备登出,需要使用新密码重新登录</p>
|
||||
</div>
|
||||
<PasswordForm />
|
||||
<LoginDevices devices={loginDevices} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -434,6 +457,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
||||
<span>邮箱仅你自己可见,用于安全通知。</span>
|
||||
</li>
|
||||
{effectiveTab === "security" && (
|
||||
<li className="flex gap-2">
|
||||
<span className="shrink-0 mt-px" style={{ color: "var(--accent)" }}>·</span>
|
||||
<span>「当前在线」是你正在使用的这台设备。其它记录可剔除,对方需重新输入密码才能登录。</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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<LoginDevice[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
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;
|
||||
|
||||
@@ -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 后密码块会再次锁上。
|
||||
|
||||
@@ -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())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user