2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 11:27:17 +08:00
Files
panel/internal/http/middleware/entrance.go
Copilot 01a228f3ad feat: 实现登录验证码和安全入口错误页伪装功能 (#1206)
* Initial plan

* feat: 实现登录验证码和安全入口错误页伪装功能

- 后端:添加登录验证码功能(密码错误3次后触发)
- 后端:支持3种安全入口错误页伪装(418/nginx/close)
- 后端:添加验证码API和更新设置项
- 前端:登录页支持验证码输入和刷新
- 前端:设置页添加登录验证码和错误页伪装选项

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复代码审查问题

- hijack失败时回退到418错误页而非返回200
- 验证码输入去除空格

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 优化细节

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-01-10 05:21:09 +08:00

174 lines
4.6 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 middleware
import (
"net"
"net/http"
"slices"
"strings"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/sessions"
"github.com/acepanel/panel/pkg/config"
"github.com/acepanel/panel/pkg/embed"
"github.com/acepanel/panel/pkg/punycode"
)
// Entrance 确保通过正确的入口访问
func Entrance(t *gotext.Locale, conf *config.Config, session *sessions.Manager) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := session.GetSession(r)
if err != nil {
Abort(w, http.StatusInternalServerError, "%v", err)
return
}
entrance := strings.TrimSuffix(conf.HTTP.Entrance, "/")
if !strings.HasPrefix(entrance, "/") {
entrance = "/" + entrance
}
// 情况一设置了绑定域名、IP、UA且请求不符合要求返回错误
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
if strings.Contains(host, "xn--") {
if decoded, err := punycode.DecodeDomain(host); err == nil {
host = decoded
}
}
if len(conf.HTTP.BindDomain) > 0 && !slices.Contains(conf.HTTP.BindDomain, host) {
abortEntrance(w, r, conf, conf.App.Locale)
return
}
// 取请求 IP
ip := r.RemoteAddr
ipHeader := conf.HTTP.IPHeader
if ipHeader != "" && r.Header.Get(ipHeader) != "" {
ip = strings.Split(r.Header.Get(ipHeader), ",")[0]
}
ip, _, err = net.SplitHostPort(strings.TrimSpace(ip))
if err != nil {
ip = r.RemoteAddr
}
if len(conf.HTTP.BindIP) > 0 {
allowed := false
requestIP := net.ParseIP(ip)
if requestIP != nil {
for _, allowedIP := range conf.HTTP.BindIP {
if strings.Contains(allowedIP, "/") {
// CIDR
if _, ipNet, err := net.ParseCIDR(allowedIP); err == nil && ipNet.Contains(requestIP) {
allowed = true
break
}
} else {
// IP
if allowedIP == ip {
allowed = true
break
}
}
}
}
if !allowed {
abortEntrance(w, r, conf, conf.App.Locale)
return
}
}
if len(conf.HTTP.BindUA) > 0 && !slices.Contains(conf.HTTP.BindUA, r.UserAgent()) {
abortEntrance(w, r, conf, conf.App.Locale)
return
}
// 情况二:请求路径与入口路径相同或未设置访问入口,标记通过验证并重定向
if (strings.TrimSuffix(r.URL.Path, "/") == entrance || entrance == "/") &&
r.Header.Get("Authorization") == "" {
sess.Put("verify_entrance", true)
// 设置入口的情况下进行重定向
if entrance != "/" {
render := chix.NewRender(w, r)
defer render.Release()
render.Redirect("/login")
return
}
}
// 情况三通过APIKey+入口路径访问,重写请求路径并跳过验证
if strings.HasPrefix(r.URL.Path, entrance) && r.Header.Get("Authorization") != "" {
// 只在设置了入口路径的情况下,才进行重写
if entrance != "/" {
if rctx := chi.RouteContext(r.Context()); rctx != nil {
rctx.RoutePath = strings.TrimPrefix(rctx.RoutePath, entrance)
r.URL.Path = strings.TrimPrefix(r.URL.Path, entrance)
}
}
next.ServeHTTP(w, r)
return
}
// 情况四Webhook 访问,跳过验证
if strings.HasPrefix(r.URL.Path, "/webhook/") {
next.ServeHTTP(w, r)
return
}
// 情况五:非调试模式且未通过验证的请求,返回错误
if !conf.App.Debug &&
sess.Missing("verify_entrance") &&
r.URL.Path != "/robots.txt" {
abortEntrance(w, r, conf, conf.App.Locale)
return
}
next.ServeHTTP(w, r)
})
}
}
func abortEntrance(w http.ResponseWriter, r *http.Request, conf *config.Config, locale string) {
errorType := conf.HTTP.EntranceError
switch errorType {
case "close":
hj, ok := w.(http.Hijacker)
if ok {
conn, _, err := hj.Hijack()
if err == nil {
_ = conn.Close()
return
}
}
// 如果无法 hijack则返回空响应
w.WriteHeader(http.StatusTeapot)
return
case "nginx":
content, err := embed.ErrorFS.ReadFile("error/nginx_404.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Server", "nginx")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(content)
return
default:
fileName := "error/418.html"
if locale == "zh_CN" {
fileName = "error/418_zh_CN.html"
}
content, _ := embed.ErrorFS.ReadFile(fileName)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write(content)
return
}
}