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, }) }