93 lines
3.2 KiB
Go
93 lines
3.2 KiB
Go
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})
|
||
}
|