mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-11 18:02:14 +01:00
feat(api): add basic auth support
This commit is contained in:
@@ -5,9 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +32,8 @@ type Api struct {
|
||||
timeout time.Duration
|
||||
rootPath string
|
||||
traceHeader string
|
||||
basicAuthUsername string
|
||||
basicAuthPassword string
|
||||
disableHealthCheckLogging bool
|
||||
|
||||
routes []Route
|
||||
@@ -163,8 +163,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
|
||||
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
|
||||
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
|
||||
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
|
||||
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
|
||||
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
|
||||
|
||||
return fs
|
||||
}(),
|
||||
New: func() gotenberg.Module { return new(Api) },
|
||||
@@ -184,24 +184,28 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
|
||||
// Port from env?
|
||||
portEnvVar := flags.MustString("api-port-from-env")
|
||||
if portEnvVar != "" {
|
||||
val, ok := os.LookupEnv(portEnvVar)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("environment variable '%s' does not exist", portEnvVar)
|
||||
}
|
||||
|
||||
if val == "" {
|
||||
return fmt.Errorf("environment variable '%s' is empty", portEnvVar)
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(val)
|
||||
port, err := gotenberg.IntEnv(portEnvVar)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get int value of environment variable '%s': %w", portEnvVar, err)
|
||||
return fmt.Errorf("get API port from env: %w", err)
|
||||
}
|
||||
|
||||
a.port = port
|
||||
}
|
||||
|
||||
// Enable basic auth?
|
||||
enableBasicAuth := flags.MustBool("api-enable-basic-auth")
|
||||
if enableBasicAuth {
|
||||
basicAuthUsername, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_USERNAME")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth username from env: %w", err)
|
||||
}
|
||||
basicAuthPassword, err := gotenberg.StringEnv("GOTENBERG_API_BASIC_AUTH_PASSWORD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("get basic auth password from env: %w", err)
|
||||
}
|
||||
a.basicAuthUsername = basicAuthUsername
|
||||
a.basicAuthPassword = basicAuthPassword
|
||||
}
|
||||
|
||||
// Get routes from modules.
|
||||
mods, err := ctx.Modules(new(Router))
|
||||
if err != nil {
|
||||
@@ -394,6 +398,13 @@ func (a *Api) Start() error {
|
||||
loggerMiddleware(a.logger, disableLoggingForPaths),
|
||||
)
|
||||
|
||||
// Basic auth?
|
||||
if a.basicAuthUsername != "" {
|
||||
a.srv.Pre(
|
||||
basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the modules' middlewares in their respective stacks.
|
||||
var externalMultipartMiddlewares []Middleware
|
||||
for _, externalMiddleware := range a.externalMiddlewares {
|
||||
|
||||
@@ -58,10 +58,28 @@ func TestApi_Provision(t *testing.T) {
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "port from env: empty environment variable",
|
||||
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_USERNAME environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
err := fs.Parse([]string{"--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
|
||||
return gotenberg.NewContext(
|
||||
gotenberg.ParsedFlags{
|
||||
FlagSet: fs,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "basic auth: non-existing GOTENBERG_API_BASIC_AUTH_PASSWORD environment variable",
|
||||
ctx: func() *gotenberg.Context {
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -74,7 +92,7 @@ func TestApi_Provision(t *testing.T) {
|
||||
)
|
||||
}(),
|
||||
setEnv: func() {
|
||||
err := os.Setenv("PORT", "")
|
||||
err := os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -361,7 +379,7 @@ func TestApi_Provision(t *testing.T) {
|
||||
}
|
||||
|
||||
fs := new(Api).Descriptor().FlagSet
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT"})
|
||||
err := fs.Parse([]string{"--api-port-from-env=PORT", "--api-enable-basic-auth=true"})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
@@ -383,6 +401,14 @@ func TestApi_Provision(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = os.Setenv("GOTENBERG_API_BASIC_AUTH_USERNAME", "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
err = os.Setenv("GOTENBERG_API_BASIC_AUTH_PASSWORD", "bar")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
expectPort: 1337,
|
||||
expectMiddlewares: []Middleware{
|
||||
@@ -671,6 +697,8 @@ func TestApi_Start(t *testing.T) {
|
||||
mod.port = 3000
|
||||
mod.startTimeout = time.Duration(30) * time.Second
|
||||
mod.rootPath = "/"
|
||||
mod.basicAuthUsername = "foo"
|
||||
mod.basicAuthPassword = "bar"
|
||||
mod.disableHealthCheckLogging = true
|
||||
mod.routes = []Route{
|
||||
{
|
||||
@@ -755,6 +783,7 @@ func TestApi_Start(t *testing.T) {
|
||||
// health request.
|
||||
recorder := httptest.NewRecorder()
|
||||
healthRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
healthRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
mod.srv.ServeHTTP(recorder, healthRequest)
|
||||
if recorder.Code != http.StatusOK {
|
||||
@@ -791,6 +820,7 @@ func TestApi_Start(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, url, body)
|
||||
req.Header.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
req.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||
@@ -112,7 +114,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("rootPath", rootPath)
|
||||
|
||||
// Call the next middleware in the chain.
|
||||
return next(c)
|
||||
}
|
||||
@@ -217,6 +218,17 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuthMiddleware manages basic authentication.
|
||||
func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
|
||||
return middleware.BasicAuth(func(u string, p string, e echo.Context) (bool, error) {
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(username)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(password)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
// contextMiddleware, a middleware for "multipart/form-data" requests, sets the
|
||||
// [Context] and related context.CancelFunc in the [echo.Context] under
|
||||
// "context" and "cancel". If the process is synchronous, it also handles the
|
||||
|
||||
@@ -236,6 +236,56 @@ func TestTraceMiddleware(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthMiddleware(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
request *http.Request
|
||||
username string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
scenario: "invalid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("invalid", "invalid")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
scenario: "valid basic auth",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.SetBasicAuth("foo", "bar")
|
||||
return req
|
||||
}(),
|
||||
username: "foo",
|
||||
password: "bar",
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
srv := echo.New()
|
||||
srv.HideBanner = true
|
||||
srv.HidePort = true
|
||||
c := srv.NewContext(tc.request, recorder)
|
||||
err := basicAuthMiddleware(tc.username, tc.password)(func(c echo.Context) error {
|
||||
return nil
|
||||
})(c)
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
}
|
||||
if tc.expectError && err == nil {
|
||||
t.Fatal("expected error but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
request *http.Request
|
||||
|
||||
Reference in New Issue
Block a user