feat: 安全设置展示有效登录会话,支持剔除其它设备

按设备指纹去重并直出 IP;非当前会话可踢下线,对方需重新输入密码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 01:48:52 +08:00
parent 2ccfb5f330
commit 3a7bfe59df
16 changed files with 808 additions and 28 deletions

View File

@@ -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 {