HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入
- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed), 服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与 immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与 409 字段级错误、fail-only 登录限流(429 + Retry-After)、 设置白名单(永不序列化口令哈希) - internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、 深链回退 index.html)+ html/template 元信息注入(仅可见笔记) - cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与 每小时会话清理 - 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、 上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
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", "尚未初始化管理员口令,请先执行 pure-note 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})
|
||||
}
|
||||
Reference in New Issue
Block a user