package handler import ( "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) 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 { 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, !h.Cfg.DevMode) 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 统一的用户信息响应体(Me/Login/Refresh 共用形状) func meUserBody(user *model.User) gin.H { return gin.H{ "id": user.ID, "username": user.Username, "nickname": user.Nickname, "avatar": user.Avatar, "signature": user.Signature, "email": user.Email, "role": user.Role, } } // Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出) func (h *Handlers) Me(c *gin.Context) { claims := middleware.CurrentUser(c) if claims == nil { c.JSON(http.StatusOK, gin.H{"user": nil, "unread_count": 0}) 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 } c.JSON(http.StatusOK, gin.H{"user": meUserBody(user), "unread_count": unread}) }