HTTP reverse proxy for Tailscale

add basic auth funnel handler

+96
+25
tsproxy.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/subtle" 5 6 "crypto/tls" 6 7 "encoding/json" 7 8 "errors" ··· 76 77 Issuer string 77 78 ClientID string 78 79 ClientSecret string 80 + User string 81 + Password string 79 82 } 80 83 81 84 type target struct { ··· 332 335 wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) 333 336 334 337 handler = wrapper.Wrap(oidcFunnel(log, lc, proxy)) 338 + case funnel.User != "": 339 + handler = insecureFunnel(log, lc, basicAuth(log, funnel.User, funnel.Password, proxy)) 335 340 default: 336 341 return fmt.Errorf("upstream %s must set funnel.insecure or funnel.issuer", upstream.Name) 337 342 } ··· 373 378 return 374 379 } 375 380 next.ServeHTTP(w, r) 381 + }) 382 + } 383 + 384 + func basicAuth(logger *slog.Logger, user, password string, next http.Handler) http.Handler { 385 + if user == "" || password == "" { 386 + panic("user and password are required") 387 + } 388 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 389 + u, p, ok := r.BasicAuth() 390 + if ok { 391 + userCheck := subtle.ConstantTimeCompare([]byte(user), []byte(u)) 392 + passwordCheck := subtle.ConstantTimeCompare([]byte(password), []byte(p)) 393 + if userCheck == 1 && passwordCheck == 1 { 394 + next.ServeHTTP(w, r) 395 + return 396 + } 397 + } 398 + logger.ErrorContext(r.Context(), "authentication failed", slog.String("user", u)) 399 + w.Header().Set("WWW-Authenticate", "Basic realm=\"protected\", charset=\"UTF-8\"") 400 + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 376 401 }) 377 402 } 378 403
+71
tsproxy_test.go
··· 310 310 } 311 311 } 312 312 313 + func TestBasicAuthHandler(t *testing.T) { 314 + t.Parallel() 315 + 316 + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) 317 + 318 + for _, tc := range []struct { 319 + name string 320 + user string 321 + password string 322 + request func(*http.Request) 323 + wantNext bool 324 + wantStatus int 325 + }{ 326 + { 327 + name: "no basic auth provided", 328 + user: "admin", 329 + password: "secret", 330 + request: func(_ *http.Request) {}, 331 + wantStatus: http.StatusUnauthorized, 332 + }, 333 + { 334 + name: "wrong user", 335 + user: "admin", 336 + password: "secret", 337 + request: func(r *http.Request) { r.SetBasicAuth("bad", "secret") }, 338 + wantStatus: http.StatusUnauthorized, 339 + }, 340 + { 341 + name: "wrong password", 342 + user: "admin", 343 + password: "secret", 344 + request: func(r *http.Request) { r.SetBasicAuth("admin", "bad") }, 345 + wantStatus: http.StatusUnauthorized, 346 + }, 347 + { 348 + name: "ok", 349 + user: "admin", 350 + password: "secret", 351 + request: func(r *http.Request) { r.SetBasicAuth("admin", "secret") }, 352 + wantNext: true, 353 + wantStatus: http.StatusOK, 354 + }, 355 + } { 356 + t.Run(tc.name, func(t *testing.T) { 357 + t.Parallel() 358 + 359 + var nextReq *http.Request 360 + h := basicAuth(logger, tc.user, tc.password, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 361 + nextReq = r 362 + fmt.Fprintf(w, "OK") 363 + })) 364 + w := httptest.NewRecorder() 365 + req := httptest.NewRequest("", "/", nil) 366 + tc.request(req) 367 + h.ServeHTTP(w, req) 368 + resp := w.Result() 369 + 370 + if want, got := tc.wantStatus, resp.StatusCode; want != got { 371 + t.Errorf("want status %d, got: %d", want, got) 372 + } 373 + 374 + if tc.wantNext && nextReq == nil { 375 + t.Fatalf("next handler not called") 376 + } 377 + if !tc.wantNext && nextReq != nil { 378 + t.Fatalf("next handler should not have been called") 379 + } 380 + }) 381 + } 382 + } 383 + 313 384 func TestServeDiscovery(t *testing.T) { 314 385 t.Parallel() 315 386