feat: 登录设备管理与编辑器图片上传

- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
This commit is contained in:
2026-09-26 01:10:10 +08:00
parent 5270fee2b7
commit 31f5360c26
12 changed files with 706 additions and 165 deletions

View File

@@ -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 加载用户并实时校验封禁状态