feat(api): add basic auth support

This commit is contained in:
Julien Neuhart
2024-04-05 17:20:34 +02:00
parent 6a9c95b3b1
commit 75c4a4f253
9 changed files with 305 additions and 21 deletions

View File

@@ -33,6 +33,9 @@ API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0
CHROMIUM_MAX_QUEUE_SIZE=0
@@ -79,6 +82,8 @@ WEBHOOK_DISABLE=false
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
$(DOCKER_REPOSITORY)/gotenberg:$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
@@ -88,6 +93,7 @@ run: ## Start a Gotenberg container
--api-timeout=$(API_TIMEOUT) \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \

2
go.mod
View File

@@ -48,6 +48,7 @@ require (
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hhrutter/lzw v1.0.0 // indirect
github.com/hhrutter/tiff v1.0.1 // indirect
@@ -63,6 +64,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

4
go.sum
View File

@@ -32,6 +32,8 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q=
github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -156,6 +158,8 @@ golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=

35
pkg/gotenberg/env.go Normal file
View File

@@ -0,0 +1,35 @@
package gotenberg
import (
"fmt"
"os"
"strconv"
)
// StringEnv retrieves the value of the environment variable named by the key.
// If the variable is present in the environment and not empty, the value is
// returned.
func StringEnv(key string) (string, error) {
val, ok := os.LookupEnv(key)
if !ok {
return "", fmt.Errorf("environment variable '%s' does not exist", key)
}
if val == "" {
return "", fmt.Errorf("environment variable '%s' is empty", key)
}
return val, nil
}
// IntEnv relies on [StringEnv] and converts the values if it exists and is not
// empty.
func IntEnv(key string) (int, error) {
val, err := StringEnv(key)
if err != nil {
return 0, err
}
intVal, err := strconv.Atoi(val)
if err != nil {
return 0, fmt.Errorf("get int value of environment variable '%s': %w", key, err)
}
return intVal, nil
}

134
pkg/gotenberg/env_test.go Normal file
View File

@@ -0,0 +1,134 @@
package gotenberg
import (
"os"
"testing"
)
func TestStringEnv(t *testing.T) {
for _, tc := range []struct {
scenario string
key string
setEnv func()
expectVal string
expectError bool
}{
{
scenario: "non-existing environment variable",
key: "NON_EXISTING",
expectVal: "",
expectError: true,
},
{
scenario: "empty environment variable",
key: "EMPTY_STRING",
setEnv: func() {
err := os.Setenv("EMPTY_STRING", "")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: "",
expectError: true,
},
{
scenario: "success",
key: "EXISTING_STRING_VALUE",
setEnv: func() {
err := os.Setenv("EXISTING_STRING_VALUE", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: "foo",
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.setEnv != nil {
tc.setEnv()
}
val, err := StringEnv(tc.key)
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")
}
if tc.expectVal != val {
t.Errorf("expected value '%s' but got '%s'", tc.expectVal, val)
}
})
}
}
func TestIntEnv(t *testing.T) {
for _, tc := range []struct {
scenario string
key string
setEnv func()
expectVal int
expectError bool
}{
{
scenario: "empty environment variable",
key: "EMPTY_INT",
setEnv: func() {
err := os.Setenv("EMPTY_INT", "")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 0,
expectError: true,
},
{
scenario: "non-integer value",
key: "NON_INTEGER",
setEnv: func() {
err := os.Setenv("NON_INTEGER", "foo")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 0,
expectError: true,
},
{
scenario: "success",
key: "EXISTING_INT_VALUE",
setEnv: func() {
err := os.Setenv("EXISTING_INT_VALUE", "123")
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
},
expectVal: 123,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.setEnv != nil {
tc.setEnv()
}
val, err := IntEnv(tc.key)
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")
}
if tc.expectVal != val {
t.Errorf("expected value %d but got %d", tc.expectVal, val)
}
})
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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