Files
jiang13-bbs/backend/handler/auth.go
freefire a7b6421840 feat: 实现完整的板块级RBAC内容审核系统
- 新增站长/超级管理员/管理员/板块管理员四级角色体系
- 实现实时权限快照加载与细粒度权限校验
- 新增内容审核队列与前后端页面
- 重构用户权限管理与站点管理逻辑
- 新增评论/帖子审核状态与通知推送
- 优化前端权限控制与角色徽章展示
- 修正数据库迁移与默认值问题
2026-09-15 04:47:34 +08:00

292 lines
9.8 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"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"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。
// 除当前名称外,还需兼容清除:
// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
// - 更早期 Path=/api/auth 的旧 refresh cookie
func clearAuthCookies(c *gin.Context, secure bool) {
cookies := []struct{ name, path string }{
{service.CookieName, "/"},
{service.RefreshCookieName, "/"},
{service.CSRFCookieName, "/"},
}
// 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
if service.CookieName != "j13_token" {
cookies = append(cookies,
struct{ name, path string }{"j13_token", "/"},
struct{ name, path string }{"j13_refresh", "/"},
struct{ name, path string }{"j13_csrf", "/"},
)
}
// 更早期的 refresh cookie 只挂在 /api/auth 下
cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
Path: ck.path,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
})
}
}
// RegisterRequest 注册请求
type RegisterRequest struct {
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"`
Password string `json:"password" binding:"required"`
}
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
func (h *Handlers) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.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
}
// 自动登录:走与 Login 相同的 token 签发链路
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password)
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
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password)
if err != nil {
// 登录失败也留痕:用户存在时带 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
setAuthCookies(c, accessToken, refreshToken, !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,
},
})
}
// 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)
if err != nil {
clearAuthCookies(c, !h.Cfg.DevMode)
if errors.Is(err, service.ErrAccountBanned) {
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
return
}
c.JSON(http.StatusUnauthorized, 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": "密码修改成功,请重新登录"})
}
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组
func meUserBody(user *model.User, boardIDs []uint) gin.H {
if boardIDs == nil {
boardIDs = []uint{}
}
return 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,
}
}
// 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}
// 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})
return
}
var unread int64
if n, err := h.Notification.UnreadCount(claims.ID); err == nil {
unread = n
}
// 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染
var boardIDs []uint
if user.Role == model.RoleBoardAdmin {
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
}
c.JSON(http.StatusOK, gin.H{"user": meUserBody(user, boardIDs), "unread_count": unread})
}