新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
449 lines
16 KiB
Go
449 lines
16 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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"
|
||
)
|
||
|
||
var (
|
||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 7 天
|
||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 30 天
|
||
)
|
||
|
||
// setAuthCookies 设置认证 cookie:
|
||
// - access token(HttpOnly,7天)
|
||
// - refresh token(HttpOnly,30天,Path=/,供 Next middleware 在页面/RSC
|
||
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
|
||
// - CSRF token(JS 可读,与 refresh 同寿命(30天),双提交校验)
|
||
//
|
||
// SameSite=Lax:允许外站顶级链接进入时保留登录态(Strict 会导致从外站
|
||
// 跳转进来的第一次请求丢 cookie,把已登录用户误判为游客);状态变更请求
|
||
// 另有 CSRF 双提交 token 兜底。生产 HTTPS 下 cookie 名带 __Host- 前缀。
|
||
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
|
||
csrfToken := service.GenerateCSRFToken()
|
||
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.CookieName,
|
||
Value: accessToken,
|
||
Path: "/",
|
||
MaxAge: accessCookieMaxAge,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.RefreshCookieName,
|
||
Value: refreshToken,
|
||
Path: "/",
|
||
MaxAge: refreshCookieMaxAge,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.CSRFCookieName,
|
||
Value: csrfToken,
|
||
Path: "/",
|
||
MaxAge: refreshCookieMaxAge,
|
||
HttpOnly: false,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
}
|
||
|
||
// clearAuthCookies 清除当前契约下的认证 cookie,并顺带清历史 Path=/api/auth 与无前缀名。
|
||
func clearAuthCookies(c *gin.Context, secure bool) {
|
||
type ck struct {
|
||
name string
|
||
httpOnly bool
|
||
}
|
||
cookies := []ck{
|
||
{service.CookieName, true},
|
||
{service.RefreshCookieName, true},
|
||
{service.CSRFCookieName, false},
|
||
// 历史无 __Host- 前缀(切换 DEV_MODE 后避免双 cookie)
|
||
{"j13_token", true},
|
||
{"j13_refresh", true},
|
||
{"j13_csrf", false},
|
||
{"__Host-j13_token", true},
|
||
{"__Host-j13_refresh", true},
|
||
{"__Host-j13_csrf", false},
|
||
}
|
||
paths := []string{"/", "/api/auth"}
|
||
for _, ck := range cookies {
|
||
for _, path := range paths {
|
||
// __Host- 要求 Secure=true 且 Path=/;非 / 路径跳过 Host 前缀名
|
||
if strings.HasPrefix(ck.name, "__Host-") && path != "/" {
|
||
continue
|
||
}
|
||
sec := secure
|
||
if strings.HasPrefix(ck.name, "__Host-") {
|
||
sec = true
|
||
}
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: ck.name,
|
||
Value: "",
|
||
Path: path,
|
||
MaxAge: -1,
|
||
HttpOnly: ck.httpOnly,
|
||
Secure: sec,
|
||
SameSite: http.SameSiteLaxMode,
|
||
Expires: time.Unix(0, 0),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// RegisterRequest 注册请求
|
||
type RegisterRequest struct {
|
||
Code string `json:"code"`
|
||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||
Email string `json:"email" binding:"omitempty,email"`
|
||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||
}
|
||
|
||
// LoginRequest 登录请求
|
||
type LoginRequest struct {
|
||
Username string `json:"username" binding:"required,max=128"`
|
||
Password string `json:"password" binding:"required,max=128"`
|
||
}
|
||
|
||
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
|
||
func (h *Handlers) Register(c *gin.Context) {
|
||
if allow, err := h.Setting.AllowRegister(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册暂时不可用"})
|
||
return
|
||
} else if !allow {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "站点已关闭注册"})
|
||
return
|
||
}
|
||
var req RegisterRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
security, err := h.Ops.Security()
|
||
if err != nil {
|
||
c.JSON(503, gin.H{"error": "注册暂不可用"})
|
||
return
|
||
}
|
||
if security.VerifyEmail {
|
||
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
|
||
var te *service.CodeThrottleError
|
||
if errors.As(err, &te) {
|
||
c.Header("Retry-After", strconv.Itoa(te.Wait))
|
||
c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait})
|
||
} else {
|
||
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||
}
|
||
return
|
||
}
|
||
}
|
||
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
_ = h.Chat.EnsureDefaultMembership(user.ID)
|
||
// 自动登录:走与 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{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
})
|
||
return
|
||
}
|
||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||
h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user": gin.H{
|
||
"id": loginUser.ID,
|
||
"username": loginUser.Username,
|
||
"nickname": loginUser.Nickname,
|
||
"avatar": loginUser.Avatar,
|
||
"role": loginUser.Role,
|
||
},
|
||
})
|
||
}
|
||
|
||
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
|
||
func (h *Handlers) Login(c *gin.Context) {
|
||
var req LoginRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
cfg, e := h.Ops.Security()
|
||
if !h.quotaResponse(c, 0, e) {
|
||
return
|
||
}
|
||
wait, e := h.Ops.FailureWait(req.Username, c.ClientIP(), cfg)
|
||
if !h.quotaResponse(c, wait, e) {
|
||
return
|
||
}
|
||
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
|
||
h.AdminUser.RecordLogin(
|
||
h.Auth.GetUserIDByUsername(req.Username), req.Username,
|
||
c.ClientIP(), c.Request.UserAgent(), false,
|
||
)
|
||
// 封禁与凭据错误区分:前端可据此展示针对性提示
|
||
if errors.Is(err, service.ErrAccountBanned) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "account_banned"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
// 登录成功留痕(IP/UA/时间)
|
||
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
|
||
// 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,
|
||
"username": user.Username,
|
||
"nickname": user.Nickname,
|
||
"avatar": user.Avatar,
|
||
"role": user.Role,
|
||
},
|
||
})
|
||
}
|
||
|
||
// Refresh 刷新 access token:验证 refresh token,轮转后签发新 access + refresh
|
||
func (h *Handlers) Refresh(c *gin.Context) {
|
||
refreshToken, err := c.Cookie(service.RefreshCookieName)
|
||
if err != nil || refreshToken == "" {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||
return
|
||
}
|
||
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
||
if err != nil {
|
||
if errors.Is(err, service.ErrAccountBanned) {
|
||
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
||
return
|
||
}
|
||
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
|
||
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
|
||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||
return
|
||
}
|
||
// 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新,
|
||
// 前端按瞬态失败处理(不弹"登录已失效"、不强制重登)
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
|
||
return
|
||
}
|
||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user": gin.H{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
"nickname": user.Nickname,
|
||
"avatar": user.Avatar,
|
||
"role": user.Role,
|
||
},
|
||
})
|
||
}
|
||
|
||
// Logout 登出:仅撤销本次请求携带的那枚 refresh token(单设备登出语义,
|
||
// 不影响该用户其他设备的登录态),并清除所有认证 cookie。
|
||
// access JWT 登出后 tv 不递增,但链头已撤销 → familySessionActive 为 false,
|
||
// ValidateClaims 在每个请求上即时拒绝旧 JWT,长 TTL 下亦无残留有效窗口。
|
||
func (h *Handlers) Logout(c *gin.Context) {
|
||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
|
||
_ = h.Auth.RevokeRefreshToken(refreshToken)
|
||
}
|
||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
|
||
}
|
||
|
||
// ChangePasswordRequest 修改密码请求
|
||
type ChangePasswordRequest struct {
|
||
OldPassword string `json:"old_password" binding:"required"`
|
||
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
|
||
}
|
||
|
||
// ChangePassword 修改密码:校验旧密码,更新新密码,清除所有登录态
|
||
func (h *Handlers) ChangePassword(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
var req ChangePasswordRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := h.Auth.ChangePassword(claims.ID, req.OldPassword, req.NewPassword); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
|
||
clearAuthCookies(c, !h.Cfg.DevMode)
|
||
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, 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
|
||
}
|
||
// 被剔除设备即时感知: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})
|
||
}
|
||
|
||
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
|
||
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组。
|
||
// badges 为管理员颁发徽章(Header 用户菜单展示;查询失败时省略)。
|
||
func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin.H {
|
||
if boardIDs == nil {
|
||
boardIDs = []uint{}
|
||
}
|
||
body := gin.H{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
"nickname": user.Nickname,
|
||
"avatar": user.Avatar,
|
||
"signature": user.Signature,
|
||
"email": user.Email,
|
||
"role": user.Role,
|
||
"board_ids": boardIDs,
|
||
"can_manage_messages": user.CanManageMessages,
|
||
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
|
||
// 最终权限以后端 RequirePerm 校验为准
|
||
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
|
||
"level": user.Level,
|
||
"total_points": user.TotalPoints,
|
||
}
|
||
if badges != nil {
|
||
body["badges"] = badges
|
||
}
|
||
return body
|
||
}
|
||
|
||
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
|
||
func (h *Handlers) Me(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
if claims == nil {
|
||
resp := gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0}
|
||
// OptionalAuth 识别到封禁:HTTP 仍为 200(/me 不阻断页面),
|
||
// 但带 banned/code,客户端静默校正时据此强制下线并弹告知
|
||
if c.GetBool(middleware.AccountBannedKey) {
|
||
resp["banned"] = true
|
||
resp["code"] = "account_banned"
|
||
}
|
||
c.JSON(http.StatusOK, resp)
|
||
return
|
||
}
|
||
user, err := h.Auth.GetUserByID(claims.ID)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0, "chat_unread_count": 0})
|
||
return
|
||
}
|
||
var unread int64
|
||
if n, err := h.Notification.UnreadCount(claims.ID); err == nil {
|
||
unread = n
|
||
}
|
||
chatUnread := int64(0)
|
||
if h.Setting != nil && h.Chat != nil {
|
||
if allow, err := h.Setting.AllowMessages(); err == nil && allow {
|
||
chatUnread = h.Chat.TotalUnread(claims.ID)
|
||
}
|
||
}
|
||
// 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染
|
||
var boardIDs []uint
|
||
if user.Role == model.RoleBoardAdmin {
|
||
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
||
}
|
||
// 颁发徽章(Header 用户菜单展示)
|
||
var badges []model.UserBadge
|
||
if h.Badge != nil {
|
||
badges, _ = h.Badge.ForUser(claims.ID)
|
||
}
|
||
body := meUserBody(user, boardIDs, badges)
|
||
// 群管理员数量:非站长/超管用于后台消息入口判定
|
||
if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin {
|
||
if n, err := h.Chat.CountAdminRooms(user.ID); err == nil {
|
||
body["chat_admin_room_count"] = n
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user": body,
|
||
"unread_count": unread,
|
||
"chat_unread_count": chatUnread,
|
||
})
|
||
}
|