This commit is contained in:
+36
-11
@@ -10,7 +10,19 @@ import (
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
)
|
||||
|
||||
// routeSpec 是一条 API 路由的注册信息;测试据此遍历全部路由验证鉴权覆盖。
|
||||
type routeSpec struct {
|
||||
Pattern string
|
||||
Handler http.HandlerFunc
|
||||
Public bool
|
||||
}
|
||||
|
||||
func NewRouter(deps Deps) http.Handler {
|
||||
handler, _ := newRouterWithSpecs(deps)
|
||||
return handler
|
||||
}
|
||||
|
||||
func newRouterWithSpecs(deps Deps) (http.Handler, []routeSpec) {
|
||||
logsHandler := NewLogsHandler(deps.Config)
|
||||
r := &Router{
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
@@ -33,17 +45,22 @@ func NewRouter(deps Deps) http.Handler {
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
||||
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||
mux.HandleFunc("GET /api/status-page", r.statusPageHandler.Get)
|
||||
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
|
||||
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
|
||||
|
||||
protect := func(pattern string, handler http.HandlerFunc) {
|
||||
mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
|
||||
var specs []routeSpec
|
||||
public := func(pattern string, handler http.HandlerFunc) {
|
||||
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler, Public: true})
|
||||
}
|
||||
protected := func(pattern string, handler http.HandlerFunc) {
|
||||
specs = append(specs, routeSpec{Pattern: pattern, Handler: handler})
|
||||
}
|
||||
|
||||
public("GET /api/version", Version(deps.Config))
|
||||
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||
public("GET /api/status-page", r.statusPageHandler.Get)
|
||||
public("GET /api/auth/status", r.authHandler.Status)
|
||||
public("POST /api/auth/login", r.authHandler.Login)
|
||||
public("POST /api/auth/logout", r.authHandler.Logout)
|
||||
|
||||
protect := protected
|
||||
|
||||
protect("POST /api/auth/password", r.authHandler.ChangePassword)
|
||||
protect("GET /api/settings/tokens", r.authHandler.ListTokens)
|
||||
@@ -177,11 +194,19 @@ func NewRouter(deps Deps) http.Handler {
|
||||
mux.Handle("/", r.spaHandler())
|
||||
}
|
||||
|
||||
for _, spec := range specs {
|
||||
if spec.Public {
|
||||
mux.HandleFunc(spec.Pattern, spec.Handler)
|
||||
continue
|
||||
}
|
||||
mux.Handle(spec.Pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(spec.Handler)))
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
|
||||
return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux)), specs
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fonu/fonu/internal/auth"
|
||||
"github.com/fonu/fonu/internal/certificate"
|
||||
"github.com/fonu/fonu/internal/config"
|
||||
"github.com/fonu/fonu/internal/db"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/secret"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
var routeParamPattern = regexp.MustCompile(`\{[^}]+\}`)
|
||||
|
||||
// TestProtectedRoutesRequireAuth 遍历路由表:受保护路由匿名必须 401,公开路由不能被鉴权误拦。
|
||||
func TestProtectedRoutesRequireAuth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conn, err := db.Open(filepath.Join(dir, "fonu.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
authSvc := auth.New(conn)
|
||||
box, err := secret.NewBox("router-auth-test-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
frpSvc := frp.NewService(cfg, frp.NewStore(conn), box, logger, certificate.NewStore(conn))
|
||||
|
||||
handler, specs := newRouterWithSpecs(Deps{
|
||||
Config: cfg,
|
||||
Auth: authSvc,
|
||||
FRPMulti: frpSvc,
|
||||
Settings: settings.NewStore(conn),
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
protected, public := 0, 0
|
||||
for _, spec := range specs {
|
||||
req := httptest.NewRequest(routeMethod(spec.Pattern), sampleRoutePath(spec.Pattern), nil)
|
||||
code := callRouteSafely(handler, req)
|
||||
if spec.Public {
|
||||
public++
|
||||
if code == http.StatusUnauthorized {
|
||||
t.Fatalf("公开路由 %s 不应被鉴权拦截", spec.Pattern)
|
||||
}
|
||||
continue
|
||||
}
|
||||
protected++
|
||||
if code != http.StatusUnauthorized {
|
||||
t.Fatalf("受保护路由 %s 匿名请求应返回 401,实际 %d", spec.Pattern, code)
|
||||
}
|
||||
}
|
||||
if protected < 100 {
|
||||
t.Fatalf("受保护路由覆盖不足:%d 条", protected)
|
||||
}
|
||||
if public < 4 {
|
||||
t.Fatalf("公开路由覆盖不足:%d 条", public)
|
||||
}
|
||||
}
|
||||
|
||||
func routeMethod(pattern string) string {
|
||||
method, _, _ := strings.Cut(pattern, " ")
|
||||
return method
|
||||
}
|
||||
|
||||
func sampleRoutePath(pattern string) string {
|
||||
_, path, _ := strings.Cut(pattern, " ")
|
||||
return routeParamPattern.ReplaceAllString(path, "1")
|
||||
}
|
||||
|
||||
// callRouteSafely 在依赖缺失导致 handler panic 时返回 0,避免公开路由的构造依赖影响鉴权断言。
|
||||
func callRouteSafely(handler http.Handler, req *http.Request) (code int) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
code = 0
|
||||
}
|
||||
}()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
Reference in New Issue
Block a user