Files
jiang13-bbs/backend/handler/auth.go
freefire 3e0689fe98 fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录
- 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配
- 访问来源统计查询优化与测试、管理端 analytics sources 页面适配
- 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
2026-09-28 03:33:52 +08:00

445 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
)
// setAuthCookies 设置认证 cookie:
// - access token(HttpOnly,15min)
// - refresh token(HttpOnly,7天,Path=/,供 Next middleware 在页面/RSC
// 请求中读取并静默轮转;仅 /api/auth/refresh 端点消费)
// - CSRF token(JS 可读,7天,双提交校验)
//
// 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 无状态、15 分钟自然过期;登出后 tv 不递增,属可接受的短窗口。
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,
"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,
})
}