From 7a1736268c312400a25502e517faad507c840422 Mon Sep 17 00:00:00 2001 From: Victoria Dye Date: Fri, 5 May 2023 15:42:51 -0700 Subject: [PATCH] bundle-server: implement simple user/pass auth middleware Create and test a built-in auth mode for a single fixed username/password authenticated against a provided 'Authorization: Basic' header, implementing the AuthMiddleware interface. Add the mode to 'parseAuthConfig()' in 'git-bundle-web-server' to enable its usage in the web server. Update documentation for the mode in 'docs/technical/auth-config.md' and 'git-bundle-web-server' manpage, and an explicit example JSON in the 'examples/auth/config' directory. Signed-off-by: Victoria Dye --- cmd/git-bundle-web-server/main.go | 3 + docs/man/git-bundle-web-server.adoc | 20 ++++ docs/technical/auth-config.md | 106 +++++++++++++++++- examples/auth/README.md | 19 ++++ examples/auth/config/fixed.json | 7 ++ internal/auth/middleware.go | 78 +++++++++++++ internal/auth/middleware_test.go | 164 ++++++++++++++++++++++++++++ 7 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 examples/auth/README.md create mode 100644 examples/auth/config/fixed.json create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/middleware_test.go diff --git a/cmd/git-bundle-web-server/main.go b/cmd/git-bundle-web-server/main.go index 371fdc1..0096f9c 100644 --- a/cmd/git-bundle-web-server/main.go +++ b/cmd/git-bundle-web-server/main.go @@ -10,6 +10,7 @@ import ( "github.com/git-ecosystem/git-bundle-server/cmd/utils" "github.com/git-ecosystem/git-bundle-server/internal/argparse" + auth_internal "github.com/git-ecosystem/git-bundle-server/internal/auth" "github.com/git-ecosystem/git-bundle-server/internal/log" "github.com/git-ecosystem/git-bundle-server/pkg/auth" ) @@ -27,6 +28,8 @@ func parseAuthConfig(configPath string) (auth.AuthMiddleware, error) { } switch strings.ToLower(config.AuthMode) { + case "fixed": + return auth_internal.NewFixedCredentialAuth(config.Parameters) default: return nil, fmt.Errorf("unrecognized auth mode '%s'", config.AuthMode) } diff --git a/docs/man/git-bundle-web-server.adoc b/docs/man/git-bundle-web-server.adoc index 0ef6a18..871e27b 100644 --- a/docs/man/git-bundle-web-server.adoc +++ b/docs/man/git-bundle-web-server.adoc @@ -40,11 +40,31 @@ The auth config JSON contains the following fields: *mode* (string):: The auth mode to use. Not case-sensitive. ++ +Available options: + + - _fixed_ *parameters* (object):: A structure containing mode-specific key-value configuration fields, if applicable. May be optional, depending on *mode*. +=== Examples + +Static, server-wide username & password ("admin" & "bundle_server", +respectively): + +[source,json] +---- +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "c3c3520adf2f6e25672ba55dc70bcb3dd8b4ef3341bce1a5f38c5eca6571f372" + } +} +---- + == SEE ALSO man:git-bundle-server[1], man:git-bundle[1], man:git-fetch[1] diff --git a/docs/technical/auth-config.md b/docs/technical/auth-config.md index 3cb9319..bdf624c 100644 --- a/docs/technical/auth-config.md +++ b/docs/technical/auth-config.md @@ -22,7 +22,11 @@ The JSON file contains the following fields: mode string - The auth mode to use. Not case-sensitive. +

The auth mode to use. Not case-sensitive.

+ Available options: + @@ -35,3 +39,103 @@ The JSON file contains the following fields: + +## Built-in modes + +### Fixed/single-user auth (server-wide) + +**Mode: `fixed`** + +This mode implements [Basic authentication][basic-rfc], authenticating each +request against a fixed username/password pair that is global to the web server. + +[basic-rfc]: https://datatracker.ietf.org/doc/html/rfc7617 + +#### Parameters + +The `parameters` object _must_ be specified for this mode, and both of the +fields below are required. + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
usernamestring + The username string for authentication. The username must + not contain a colon (":"). +
passwordHashstring +

+ The SHA256 hash of the password string. There are no + restrictions on characters used for the password. +

+

+ The hash of a string can be generated on the command line + with the command + echo -n '<your string>' | shasum -a 256. +

+
+ +#### Examples + +Valid (username `admin`, password `test`): + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } +} +``` + +Valid (empty username & password): + +```json +{ + "mode": "fixed", + "parameters": { + "usernameHash": "", + "passwordHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } +} +``` + +Invalid: + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "test123" + } +} +``` + +Invalid: + +```json +{ + "mode": "fixed", + "parameters": { + "username": "admin:MY_PASSWORD", + } +} +``` diff --git a/examples/auth/README.md b/examples/auth/README.md new file mode 100644 index 0000000..ec4de4f --- /dev/null +++ b/examples/auth/README.md @@ -0,0 +1,19 @@ +# Auth configuration examples + +This directory contains examples of auth configurations that may be used as a +reference for setting up auth for a bundle server. + +> **Warning** +> +> The examples contained within this directory should not be used directly in a +> production context due to publicly-visible (in this repo) credentials. + +## Built-in modes + +### Fixed credential/single-user auth + +The file [`config/fixed.json`][fixed-config] configures [Basic +authentication][basic] with username "admin" and password "bundle_server". + +[fixed-config]: ./config/fixed.json +[basic]: ../../docs/technical/auth-config.md#basic-auth-server-wide diff --git a/examples/auth/config/fixed.json b/examples/auth/config/fixed.json new file mode 100644 index 0000000..716d712 --- /dev/null +++ b/examples/auth/config/fixed.json @@ -0,0 +1,7 @@ +{ + "mode": "fixed", + "parameters": { + "username": "admin", + "passwordHash": "c3c3520adf2f6e25672ba55dc70bcb3dd8b4ef3341bce1a5f38c5eca6571f372" + } +} \ No newline at end of file diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..580b78d --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,78 @@ +package auth + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/git-ecosystem/git-bundle-server/pkg/auth" +) + +/* Built-in auth modes */ +// Authorize users with a credentials matching a static username/password pair +// that applies to the whole server. +type fixedCredentialAuth struct { + usernameHash [32]byte + passwordHash [32]byte +} + +type fixedCredentialAuthParams struct { + Username string `json:"username"` + PasswordHash string `json:"passwordHash"` +} + +func NewFixedCredentialAuth(rawParameters json.RawMessage) (auth.AuthMiddleware, error) { + if len(rawParameters) == 0 { + return nil, fmt.Errorf("parameters JSON must exist") + } + + var params fixedCredentialAuthParams + err := json.Unmarshal(rawParameters, ¶ms) + if err != nil { + return nil, err + } + + // Check for invalid username characters + if strings.Contains(params.Username, ":") { + return nil, fmt.Errorf("username contains a colon (\":\")") + } + + // Make sure password hash is a valid hash + passwordHashBytes, err := hex.DecodeString(params.PasswordHash) + if err != nil { + return nil, fmt.Errorf("passwordHash is invalid: %w", err) + } else if len(passwordHashBytes) != 32 { + return nil, fmt.Errorf("passwordHash is incorrect length (%d vs. expected 32)", len(passwordHashBytes)) + } + + return &fixedCredentialAuth{ + usernameHash: sha256.Sum256([]byte(params.Username)), + passwordHash: [32]byte(passwordHashBytes), + }, nil +} + +func (a *fixedCredentialAuth) Authorize(r *http.Request, _ string, _ string) auth.AuthResult { + username, password, ok := r.BasicAuth() + if ok { + usernameHash := sha256.Sum256([]byte(username)) + passwordHash := sha256.Sum256([]byte(password)) + + usernameMatch := (subtle.ConstantTimeCompare(usernameHash[:], a.usernameHash[:]) == 1) + passwordMatch := (subtle.ConstantTimeCompare(passwordHash[:], a.passwordHash[:]) == 1) + + if usernameMatch && passwordMatch { + return auth.Allow() + } else { + // Return a 404 status even though the issue is that the user is + // forbidden so we don't indirectly reveal which repositories are + // configured in the bundle server. + return auth.Deny(404) + } + } + + return auth.Deny(401, auth.Header{Key: "WWW-Authenticate", Value: `Basic realm="restricted", charset="UTF-8"`}) +} diff --git a/internal/auth/middleware_test.go b/internal/auth/middleware_test.go new file mode 100644 index 0000000..b2f7a39 --- /dev/null +++ b/internal/auth/middleware_test.go @@ -0,0 +1,164 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/git-ecosystem/git-bundle-server/internal/auth" + "github.com/stretchr/testify/assert" +) + +var basicAuthTests = []struct { + title string + + // Inputs + parameters string + authHeader string + + // Expected outputs + authInitializationError bool + expectedDoExit bool + expectedResponseCode int + expectedHeaders http.Header +}{ + { + "No auth with expected username, password returns 401", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "", + false, + true, + 401, + map[string][]string{ + "Www-Authenticate": {`Basic realm="restricted", charset="UTF-8"`}, + }, + }, + { + "Garbage auth header returns 401", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic *asdf====", + false, + true, + 401, + map[string][]string{ + "Www-Authenticate": {`Basic realm="restricted", charset="UTF-8"`}, + }, + }, + { + "Incorrect username returns 404", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic aW52YWxpZDp0ZXN0MTIz", // Base64 encoded "invalid:test123" + false, + true, + 404, + map[string][]string{}, + }, + { + "Correct username and password returns Authorized", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic YWRtaW46dGVzdDEyMw==", // Base64 encoded "admin:test123" + false, + false, + 200, + nil, + }, + { + "Empty username and password with expected auth returns Forbidden", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic Og==", // Base64 encoded ":" + false, + true, + 404, + map[string][]string{}, + }, + { + "Empty username and password is valid, return Authorized", + `{ "username": "", "passwordHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }`, // password: + "Basic Og==", // Base64 encoded ":" + false, + false, + 200, + nil, + }, + { + "Extra JSON parameters are ignored", + `{ "username": "admin", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae", "extra": [true, false] }`, // password: test123 + "Basic YWRtaW46dGVzdDEyMw==", // Base64 encoded "admin:test123" + false, + false, + 200, + nil, + }, + { + "Empty parameter JSON throws error", + "{}", + "Basic Og==", // Base64 encoded ":" + true, + true, + -1, + nil, + }, + { + "Missing parameter JSON throws error", + "", + "", + true, + true, + -1, + nil, + }, + { + "Malformed parameter JSON throws error", + `{abc: "def"`, + "", // Base64 encoded ":" + true, + true, + -1, + nil, + }, + { + "Username with colon throws error", + `{ "username": "example:user", "passwordHash": "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae" }`, // password: test123 + "Basic ZXhhbXBsZTp1c2VyOnRlc3QxMjM=", // Base64 encoded "example:user:test123" + true, + true, + -1, + nil, + }, +} + +func Test_FixedCredentialAuth(t *testing.T) { + for _, tt := range basicAuthTests { + t.Run(tt.title, func(t *testing.T) { + // Construct the request + req, err := http.NewRequest("GET", "test/repo", nil) + assert.Nil(t, err) + + if len(tt.authHeader) > 0 { + req.Header.Set("Authorization", tt.authHeader) + } + + // Create the auth middleware + auth, err := auth.NewFixedCredentialAuth([]byte(tt.parameters)) + if tt.authInitializationError { + assert.NotNil(t, err) + return + } + assert.Nil(t, err) + + result := auth.Authorize(req, "test", "repo") + + wExpect := httptest.NewRecorder() + if tt.expectedDoExit { + wExpect.HeaderMap = tt.expectedHeaders //lint:ignore SA1019 set headers manually for test + wExpect.WriteHeader(tt.expectedResponseCode) + } + + wActual := httptest.NewRecorder() + actualDoExit := result.ApplyResult(wActual) + + assert.Equal(t, tt.expectedDoExit, actualDoExit) + assert.Equal(t, wExpect, wActual) + }) + } +}