feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话 - Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal) - 实时总线、账号守卫与用户事件适配
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/realtime"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -153,8 +154,8 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Chat.EnsureDefaultMembership(user.ID)
|
||||
// 自动登录:走与 Login 相同的 token 签发链路
|
||||
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
// 自动登录:走与 Login 相同的 token 签发链路(新注册无旧会话,不会有踢出广播)
|
||||
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{
|
||||
@@ -191,7 +192,7 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
if !h.quotaResponse(c, wait, e) {
|
||||
return
|
||||
}
|
||||
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
accessToken, refreshToken, user, kicked, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
|
||||
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
||||
@@ -212,6 +213,14 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
h.Ops.ClearFailure(req.Username)
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
// 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则
|
||||
// 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现)
|
||||
if kicked > 0 {
|
||||
h.Hub.BroadcastUser(user.ID, realtime.Envelope{
|
||||
Type: realtime.EventSessionReplaced,
|
||||
Data: gin.H{"reason": "new_login"},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
@@ -338,6 +347,11 @@ func (h *Handlers) RevokeLoginDevice(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"})
|
||||
return
|
||||
}
|
||||
// 被剔除设备即时感知:WS 在线则当场弹"登录已失效",无需等 token 失效
|
||||
h.Hub.BroadcastUser(claims.ID, realtime.Envelope{
|
||||
Type: realtime.EventSessionReplaced,
|
||||
Data: gin.H{"reason": "device_revoked"},
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||
)
|
||||
|
||||
// RoomStaff 管理团队房间(板块管理员及以上)
|
||||
|
||||
@@ -132,31 +132,32 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 access token + refresh token + user
|
||||
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, error) {
|
||||
// Login 用户登录,返回 access token + refresh token + user;
|
||||
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
|
||||
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
|
||||
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password))
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
return "", "", nil, 0, errors.New("用户名或密码错误")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
return "", "", nil, 0, errors.New("用户名或密码错误")
|
||||
}
|
||||
// 封禁判定放在密码比较之后,同样避免时序差异
|
||||
if user.Banned {
|
||||
return "", "", nil, ErrAccountBanned
|
||||
return "", "", nil, 0, ErrAccountBanned
|
||||
}
|
||||
|
||||
refreshToken, familyID, err := s.CreateRefreshToken(user.ID, ip, ua)
|
||||
refreshToken, familyID, kicked, err := s.CreateRefreshToken(user.ID, ip, ua)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
return "", "", nil, 0, err
|
||||
}
|
||||
accessToken, err := s.generateToken(&user, familyID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
return "", "", nil, 0, err
|
||||
}
|
||||
return accessToken, refreshToken, &user, nil
|
||||
return accessToken, refreshToken, &user, kicked, nil
|
||||
}
|
||||
|
||||
// generateToken 签发 access token(短期)
|
||||
@@ -315,12 +316,13 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)
|
||||
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, error) {
|
||||
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
|
||||
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
|
||||
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
|
||||
token := generateRandomToken()
|
||||
cipherText, err := s.encryptToken(token)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
rt := &model.RefreshToken{
|
||||
@@ -334,14 +336,14 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
|
||||
ExpiresAt: now.Add(RefreshTokenTTL),
|
||||
}
|
||||
if err := s.db.Create(rt).Error; err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if err := s.db.Model(rt).Update("family_id", rt.ID).Error; err != nil {
|
||||
return "", 0, err
|
||||
return "", 0, 0, err
|
||||
}
|
||||
rt.FamilyID = rt.ID
|
||||
s.revokeSameFingerprintExcept(userID, rt.FamilyID, ip, ua)
|
||||
return token, rt.FamilyID, nil
|
||||
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
|
||||
return token, rt.FamilyID, kicked, nil
|
||||
}
|
||||
|
||||
// loadActiveUser 加载用户并实时校验封禁状态
|
||||
|
||||
@@ -191,27 +191,30 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
|
||||
return fid
|
||||
}
|
||||
|
||||
func (s *AuthService) revokeSameFingerprintExcept(userID, keepFamily uint, ip, ua string) {
|
||||
fp := deviceFingerprint(ip, ua)
|
||||
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
|
||||
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
|
||||
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
|
||||
now := time.Now()
|
||||
var actives []model.RefreshToken
|
||||
if err := s.db.Select("id", "family_id", "ip", "user_agent").
|
||||
if err := s.db.Select("id", "family_id").
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||||
Find(&actives).Error; err != nil {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
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)
|
||||
}
|
||||
ids = append(ids, t.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
return 0
|
||||
}
|
||||
s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
|
||||
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
|
||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
|
||||
if res.Error != nil {
|
||||
return 0
|
||||
}
|
||||
return int(res.RowsAffected)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user