package httpapi import ( "errors" "net/http" "strconv" "time" "pure-note/internal/auth" "pure-note/internal/middleware" ) type loginRequest struct { Password string `json:"password"` } // handleLogin POST /api/auth/login。 // Origin 校验由全局中间件完成(含 login,§9.1-T2);此处做防爆破与口令校验。 func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "请求体不是合法 JSON") return } ip := middleware.ClientIP(r, s.cfg.BehindProxy) const account = "admin" // 单管理员账号维度 // 预检:桶已耗尽直接 429(避免无谓的 Argon2 计算),429 + Retry-After(§7.2) if !s.loginIP.Available(ip) || !s.loginAcct.Available(account) { retry := max(s.loginIP.RetryAfter(ip), s.loginAcct.RetryAfter(account)) w.Header().Set("Retry-After", strconv.Itoa(retry)) writeError(w, http.StatusTooManyRequests, "rate_limited", "尝试过于频繁,请稍后再试") return } hash, ok, err := s.st.GetSetting("admin_password_hash") if err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } if !ok { writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pn init") return } if req.Password == "" || !auth.VerifyPassword(hash, req.Password) { // 失败才计费:消费两维度令牌(fail-only,§7.3-2) s.loginIP.Allow(ip) s.loginAcct.Allow(account) // 记录 IP 与桶剩余计数(§7.2/§10.5) s.log.Warn("login_failed", "ip", ip, "ip_bucket_left", s.loginIP.Remaining(ip), "account_bucket_left", s.loginAcct.Remaining(account)) // 统一 401 文案,不泄露差异(§7.3-2) writeError(w, http.StatusUnauthorized, "invalid_credentials", "用户名或密码错误") return } // 登录成功:重建会话行(防会话固定,§7.3-3) token, err := newToken() if err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } csrf, err := newToken() if err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } now := time.Now().Unix() if err := s.st.CreateSession(hashToken(token), csrf, now, now+int64(sessionTTL.Seconds())); err != nil { writeError(w, http.StatusInternalServerError, "internal", "内部错误") return } http.SetCookie(w, s.sessionCookie(token, int(sessionTTL.Seconds()))) s.log.Info("admin_action", "op", "login", "ip", ip) writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrf}) } // handleLogout POST /api/auth/logout:删除会话行 + 清 Cookie。 func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(s.cookieName()); err == nil && c.Value != "" { if err := s.st.DeleteSession(hashToken(c.Value)); err != nil && !errors.Is(err, nil) { // 删除失败不阻断登出(幂等) s.log.Error("删除会话失败", "err", err) } } http.SetCookie(w, s.sessionCookie("", -1)) s.log.Info("admin_action", "op", "logout") writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }