后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
214 lines
6.2 KiB
Go
214 lines
6.2 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/middleware"
|
||
"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:
|
||
// - j13_token: access token(HttpOnly,15min)
|
||
// - j13_refresh: refresh token(HttpOnly,7天,仅 /api/auth 路径)
|
||
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
|
||
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
|
||
csrfToken := service.GenerateCSRFToken()
|
||
|
||
// Access token cookie
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.CookieName,
|
||
Value: accessToken,
|
||
Path: "/",
|
||
MaxAge: accessCookieMaxAge,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteStrictMode,
|
||
})
|
||
// Refresh token cookie(仅 /api/auth 路径使用,缩小攻击面)
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.RefreshCookieName,
|
||
Value: refreshToken,
|
||
Path: "/api/auth",
|
||
MaxAge: refreshCookieMaxAge,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteStrictMode,
|
||
})
|
||
// CSRF cookie(前端可读,放入 X-CSRF-Token header;与 refresh 同寿命以支持 refresh 流程)
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: service.CSRFCookieName,
|
||
Value: csrfToken,
|
||
Path: "/",
|
||
MaxAge: refreshCookieMaxAge,
|
||
HttpOnly: false,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteStrictMode,
|
||
})
|
||
}
|
||
|
||
// clearAuthCookies 清除所有认证 cookie
|
||
func clearAuthCookies(c *gin.Context) {
|
||
cookies := []struct{ name, path string }{
|
||
{service.CookieName, "/"},
|
||
{service.RefreshCookieName, "/api/auth"},
|
||
{service.CSRFCookieName, "/"},
|
||
}
|
||
for _, ck := range cookies {
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: ck.name,
|
||
Value: "",
|
||
Path: ck.path,
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
Secure: false,
|
||
SameSite: http.SameSiteStrictMode,
|
||
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
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
})
|
||
}
|
||
|
||
// 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 {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
// 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)
|
||
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
|
||
func (h *Handlers) Logout(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
if claims != nil {
|
||
// 撤销该用户所有 refresh token(防止 refresh token 被盗用)
|
||
h.Auth.RevokeAllUserRefreshTokens(claims.ID)
|
||
}
|
||
clearAuthCookies(c)
|
||
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)
|
||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
|
||
}
|
||
|
||
// Me 获取当前用户信息
|
||
func (h *Handlers) Me(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
if claims == nil {
|
||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||
return
|
||
}
|
||
user, err := h.Auth.GetUserByID(claims.ID)
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user": gin.H{
|
||
"id": user.ID,
|
||
"username": user.Username,
|
||
"nickname": user.Nickname,
|
||
"avatar": user.Avatar,
|
||
"role": user.Role,
|
||
},
|
||
})
|
||
}
|