From 7876585300cd020df31865f012a176b8dbd1dcfd Mon Sep 17 00:00:00 2001 From: Simon Rozet Date: Tue, 29 Apr 2025 23:03:18 +0200 Subject: [PATCH] add basic auth funnel handler --- tsproxy.go | 25 +++++++++++++++++ tsproxy_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/tsproxy.go b/tsproxy.go index 709d1b2..017dc5b 100644 --- a/tsproxy.go +++ b/tsproxy.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "crypto/tls" "encoding/json" "errors" @@ -76,6 +77,8 @@ type funnelConfig struct { Issuer string ClientID string ClientSecret string + User string + Password string } type target struct { @@ -332,6 +335,8 @@ func tsproxy(ctx context.Context) error { wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) handler = wrapper.Wrap(oidcFunnel(log, lc, proxy)) + case funnel.User != "": + handler = insecureFunnel(log, lc, basicAuth(log, funnel.User, funnel.Password, proxy)) default: return fmt.Errorf("upstream %s must set funnel.insecure or funnel.issuer", upstream.Name) } @@ -376,6 +381,26 @@ func redirect(fqdn string, forceSSL bool, next http.Handler) http.Handler { }) } +func basicAuth(logger *slog.Logger, user, password string, next http.Handler) http.Handler { + if user == "" || password == "" { + panic("user and password are required") + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if ok { + userCheck := subtle.ConstantTimeCompare([]byte(user), []byte(u)) + passwordCheck := subtle.ConstantTimeCompare([]byte(password), []byte(p)) + if userCheck == 1 && passwordCheck == 1 { + next.ServeHTTP(w, r) + return + } + } + logger.ErrorContext(r.Context(), "authentication failed", slog.String("user", u)) + w.Header().Set("WWW-Authenticate", "Basic realm=\"protected\", charset=\"UTF-8\"") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + }) +} + func tailnet(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { whois, err := tsWhoIs(lc, r) diff --git a/tsproxy_test.go b/tsproxy_test.go index 2c748eb..feef75d 100644 --- a/tsproxy_test.go +++ b/tsproxy_test.go @@ -310,6 +310,77 @@ func TestRedirectHandler(t *testing.T) { } } +func TestBasicAuthHandler(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + + for _, tc := range []struct { + name string + user string + password string + request func(*http.Request) + wantNext bool + wantStatus int + }{ + { + name: "no basic auth provided", + user: "admin", + password: "secret", + request: func(_ *http.Request) {}, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong user", + user: "admin", + password: "secret", + request: func(r *http.Request) { r.SetBasicAuth("bad", "secret") }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong password", + user: "admin", + password: "secret", + request: func(r *http.Request) { r.SetBasicAuth("admin", "bad") }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "ok", + user: "admin", + password: "secret", + request: func(r *http.Request) { r.SetBasicAuth("admin", "secret") }, + wantNext: true, + wantStatus: http.StatusOK, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var nextReq *http.Request + h := basicAuth(logger, tc.user, tc.password, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextReq = r + fmt.Fprintf(w, "OK") + })) + w := httptest.NewRecorder() + req := httptest.NewRequest("", "/", nil) + tc.request(req) + h.ServeHTTP(w, req) + resp := w.Result() + + if want, got := tc.wantStatus, resp.StatusCode; want != got { + t.Errorf("want status %d, got: %d", want, got) + } + + if tc.wantNext && nextReq == nil { + t.Fatalf("next handler not called") + } + if !tc.wantNext && nextReq != nil { + t.Fatalf("next handler should not have been called") + } + }) + } +} + func TestServeDiscovery(t *testing.T) { t.Parallel() -- 2.51.2