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(名称随 ConfigureCookieNames,Path=/)。 func clearAuthCookies(c *gin.Context, secure bool) { cookies := []struct { name string httpOnly bool }{ {service.CookieName, true}, {service.RefreshCookieName, true}, {service.CSRFCookieName, false}, } for _, ck := range cookies { http.SetCookie(c.Writer, &http.Cookie{ Name: ck.name, Value: "", Path: "/", MaxAge: -1, HttpOnly: ck.httpOnly, 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 } _ = h.Chat.EnsureDefaultMembership(user.ID) // 自动登录:走与 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, "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 := h.Chat.TotalUnread(claims.ID) // 板块管理员带出授权板块,供前端菜单/按钮按板块范围渲染 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, "chat_unread_count": chatUnread, }) }