Files
pure-note/internal/httpapi/auth.go
T

93 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}