fix(web,agent): P1 正确性与鉴权加固,P2 抽 fsutil 并补测试(v1.2.8)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
@@ -15,10 +16,67 @@ import (
|
||||
type AuthHandler struct {
|
||||
auth *auth.Service
|
||||
notify *notify.Service
|
||||
guard *loginGuard
|
||||
}
|
||||
|
||||
func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
|
||||
return &AuthHandler{auth: authSvc, notify: notifySvc}
|
||||
return &AuthHandler{auth: authSvc, notify: notifySvc, guard: newLoginGuard()}
|
||||
}
|
||||
|
||||
const (
|
||||
loginFailureLimit = 5
|
||||
loginLockoutWindow = 5 * time.Minute
|
||||
)
|
||||
|
||||
// loginGuard 是进程内的登录失败退避:同一 IP 在窗口内失败到阈值后直接拒绝,
|
||||
// 避免明文 HTTP 部署下被无限爆破;与用于通知的失败统计相互独立。
|
||||
type loginGuard struct {
|
||||
mu sync.Mutex
|
||||
failures map[string][]time.Time
|
||||
}
|
||||
|
||||
func newLoginGuard() *loginGuard {
|
||||
return &loginGuard{failures: make(map[string][]time.Time)}
|
||||
}
|
||||
|
||||
// blocked 返回该 IP 是否仍在锁定窗口内(顺带清理过期记录)。
|
||||
func (g *loginGuard) blocked(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
kept := g.recentLocked(ip)
|
||||
g.failures[ip] = kept
|
||||
return len(kept) >= loginFailureLimit
|
||||
}
|
||||
|
||||
func (g *loginGuard) fail(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if len(g.failures) > 1024 {
|
||||
for key := range g.failures {
|
||||
if len(g.recentLocked(key)) == 0 {
|
||||
delete(g.failures, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.failures[ip] = append(g.recentLocked(ip), time.Now())
|
||||
}
|
||||
|
||||
func (g *loginGuard) reset(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.failures, ip)
|
||||
}
|
||||
|
||||
// recentLocked 返回窗口内的失败时间(调用方需持有锁)。
|
||||
func (g *loginGuard) recentLocked(ip string) []time.Time {
|
||||
now := time.Now()
|
||||
kept := make([]time.Time, 0, len(g.failures[ip]))
|
||||
for _, at := range g.failures[ip] {
|
||||
if now.Sub(at) < loginLockoutWindow {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -27,7 +85,7 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type authStatusResponse struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Initialized bool `json:"initialized"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
@@ -55,11 +113,17 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if h.guard.blocked(ip) {
|
||||
writeError(r, w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUnauthorized) {
|
||||
h.guard.fail(ip)
|
||||
if h.notify != nil {
|
||||
h.notify.RecordLoginFailure(r.Context(), clientIP(r))
|
||||
h.notify.RecordLoginFailure(r.Context(), ip)
|
||||
}
|
||||
writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
|
||||
return
|
||||
@@ -67,7 +131,8 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusInternalServerError, "登录失败")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, sessionID)
|
||||
h.guard.reset(ip)
|
||||
setSessionCookie(w, r, sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
|
||||
}
|
||||
|
||||
@@ -86,7 +151,7 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||||
return
|
||||
}
|
||||
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil {
|
||||
if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword, sessionID); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -97,21 +162,31 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
|
||||
_ = h.auth.Logout(r.Context(), sessionID)
|
||||
}
|
||||
clearSessionCookie(w)
|
||||
clearSessionCookie(w, r)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, sessionID string) {
|
||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int((7 * 24 * time.Hour).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// requestIsHTTPS:直连 TLS 或经反代(X-Forwarded-Proto)时给会话 cookie 加 Secure;
|
||||
// 默认明文 HTTP 部署下不加,否则浏览器会直接丢弃 cookie 导致登录不了。
|
||||
func requestIsHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -120,12 +195,14 @@ func clientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "fonu_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: requestIsHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
CA string `json:"ca"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
domains := req.Domains
|
||||
if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
|
||||
domains = []string{req.Domain}
|
||||
@@ -141,7 +144,10 @@ func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
CA string `json:"ca"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
|
||||
@@ -23,13 +23,13 @@ type ddnsRequest struct {
|
||||
RecordNames []string `json:"record_names"`
|
||||
Domains []string `json:"domains"`
|
||||
IPv4Enabled *bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled *bool `json:"ipv6_enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
CustomIPv4 string `json:"custom_ipv4"`
|
||||
CustomIPv6 string `json:"custom_ipv6"`
|
||||
APIToken string `json:"api_token"`
|
||||
APITokenID string `json:"api_token_id"`
|
||||
APISecret string `json:"api_secret"`
|
||||
IPv6Enabled *bool `json:"ipv6_enabled"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
CustomIPv4 string `json:"custom_ipv4"`
|
||||
CustomIPv6 string `json:"custom_ipv6"`
|
||||
APIToken string `json:"api_token"`
|
||||
APITokenID string `json:"api_token_id"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
|
||||
@@ -119,7 +119,10 @@ func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
|
||||
APITokenID string `json:"api_token_id"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Test(r.Context(), ddns.TestInput{
|
||||
ConfigID: req.ConfigID,
|
||||
Provider: req.Provider,
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -606,7 +608,11 @@ func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Requ
|
||||
Version string `json:"version"`
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// 允许空请求体(走默认版本),但 JSON 语法错误要报 400
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||
writeError(r, w, http.StatusBadRequest, "请求格式无效")
|
||||
return
|
||||
}
|
||||
if body.Version == "" {
|
||||
body.Version = "0.71.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user