Skip to content

Commit

Permalink
add middlewares for auth with hashes
Browse files Browse the repository at this point in the history
  • Loading branch information
umputun committed Dec 9, 2024
1 parent fbfa8cb commit 9738b8e
Show file tree
Hide file tree
Showing 3 changed files with 333 additions and 0 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,58 @@ example with chi router:
router.Use(rest.Reject(http.StatusBadRequest, "X-Request-Id header is required", rejectFn))
```

### BasicAuth middleware family

The package provides several BasicAuth middleware implementations for different authentication needs:

#### BasicAuth
The base middleware that requires basic auth and matches user & passwd with a client-provided checker function.
```go
checkFn := func(user, passwd string) bool {
return user == "admin" && passwd == "secret"
}
router.Use(rest.BasicAuth(checkFn))
```

#### BasicAuthWithUserPasswd
A simpler version comparing user & password with provided values directly.
```go
router.Use(rest.BasicAuthWithUserPasswd("admin", "secret"))
```

#### BasicAuthWithBcryptHash
Matches username and bcrypt-hashed password. Useful when storing hashed passwords.
```go
hash, err := rest.GenerateBcryptHash("secret")
if err != nil {
// handle error
}
router.Use(rest.BasicAuthWithBcryptHash("admin", hash))
```

#### BasicAuthWithArgon2Hash
Similar to bcrypt version but uses Argon2id hash with a separate salt. Both hash and salt are base64 encoded.
```go
hash, salt, err := rest.GenerateArgon2Hash("secret")
if err != nil {
// handle error
}
router.Use(rest.BasicAuthWithArgon2Hash("admin", hash, salt))
```

#### BasicAuthWithPrompt
Similar to BasicAuthWithUserPasswd but adds browser's authentication prompt by setting the WWW-Authenticate header.
```go
router.Use(rest.BasicAuthWithPrompt("admin", "secret"))
```

All BasicAuth middlewares:
- Return `StatusUnauthorized` (401) if no auth header provided
- Return `StatusForbidden` (403) if credentials check failed
- Add IsAuthorized flag to the request context, retrievable with `rest.IsAuthorized(r.Context())`
- Use constant-time comparison to prevent timing attacks
- Support secure password hashing with bcrypt and Argon2id

### Benchmarks middleware

Benchmarks middleware allows measuring the time of request handling, number of requests per second and report aggregated metrics.
Expand Down
63 changes: 63 additions & 0 deletions basic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ package rest

import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"

"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)

const baContextKey = "authorizedWithBasicAuth"
Expand Down Expand Up @@ -39,6 +44,42 @@ func BasicAuthWithUserPasswd(user, passwd string) func(http.Handler) http.Handle
return BasicAuth(checkFn)
}

// BasicAuthWithBcryptHash middleware requires basic auth and matches user & bcrypt hashed password
func BasicAuthWithBcryptHash(user, hashedPassword string) func(http.Handler) http.Handler {
checkFn := func(reqUser, reqPasswd string) bool {
if reqUser != user {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(reqPasswd))
return err == nil
}
return BasicAuth(checkFn)
}

// BasicAuthWithArgon2Hash middleware requires basic auth and matches user & argon2 hashed password
// both hashedPassword and salt must be base64 encoded strings
// Uses Argon2id with parameters: t=1, m=64*1024 KB, p=4 threads
func BasicAuthWithArgon2Hash(user, hashedPassword, salt string) func(http.Handler) http.Handler {
checkFn := func(reqUser, reqPasswd string) bool {
if reqUser != user {
return false
}

saltBytes, err := base64.StdEncoding.DecodeString(salt)
if err != nil {
return false
}
storedHashBytes, err := base64.StdEncoding.DecodeString(hashedPassword)
if err != nil {
return false
}

hash := argon2.IDKey([]byte(reqPasswd), saltBytes, 1, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, storedHashBytes) == 1
}
return BasicAuth(checkFn)
}

// IsAuthorized returns true is user authorized.
// it can be used in handlers to check if BasicAuth middleware was applied
func IsAuthorized(ctx context.Context) bool {
Expand Down Expand Up @@ -71,3 +112,25 @@ func BasicAuthWithPrompt(user, passwd string) func(http.Handler) http.Handler {
return http.HandlerFunc(fn)
}
}

// GenerateBcryptHash generates a bcrypt hash from a password
func GenerateBcryptHash(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}

// GenerateArgon2Hash generates an argon2 hash and salt from a password
func GenerateArgon2Hash(password string) (hash string, salt string, err error) {
saltBytes := make([]byte, 16)
if _, err := rand.Read(saltBytes); err != nil {
return "", "", err
}

// using recommended parameters: time=1, memory=64*1024, threads=4, keyLen=32
hashBytes := argon2.IDKey([]byte(password), saltBytes, 1, 64*1024, 4, 32)

return base64.StdEncoding.EncodeToString(hashBytes), base64.StdEncoding.EncodeToString(saltBytes), nil
}
218 changes: 218 additions & 0 deletions basic_auth_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package rest

import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -9,6 +10,8 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)

func TestBasicAuth(t *testing.T) {
Expand Down Expand Up @@ -142,3 +145,218 @@ func TestBasicAuthWithPrompt(t *testing.T) {
assert.Equal(t, `Basic realm="restricted", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
}
}

func TestBasicAuthWithHash(t *testing.T) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("good"), bcrypt.MinCost)
require.NoError(t, err)
t.Logf("hashed password: %s", string(hashedPassword))

mw := BasicAuthWithBcryptHash("dev", string(hashedPassword))

ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("request %s", r.URL)
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("blah"))
require.NoError(t, err)
assert.True(t, IsAuthorized(r.Context()))
})))
defer ts.Close()

u := fmt.Sprintf("%s%s", ts.URL, "/something")
client := http.Client{Timeout: 5 * time.Second}

tests := []struct {
name string
username string
password string
expectedStatus int
}{
{
name: "no auth provided",
username: "",
password: "",
expectedStatus: http.StatusUnauthorized,
},
{
name: "correct credentials",
username: "dev",
password: "good",
expectedStatus: http.StatusOK,
},
{
name: "wrong username",
username: "wrong",
password: "good",
expectedStatus: http.StatusForbidden,
},
{
name: "wrong password",
username: "dev",
password: "bad",
expectedStatus: http.StatusForbidden,
},
{
name: "empty password",
username: "dev",
password: "",
expectedStatus: http.StatusForbidden,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("GET", u, http.NoBody)
require.NoError(t, err)

if tc.username != "" || tc.password != "" {
req.SetBasicAuth(tc.username, tc.password)
}

resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
})
}
}

func TestBasicAuthWithArgon2Hash(t *testing.T) {
password := "good"
hash, salt, err := GenerateArgon2Hash(password)
require.NoError(t, err)
t.Logf("hash: %s, salt: %s", hash, salt)

// verify the returned values are valid base64
_, err = base64.StdEncoding.DecodeString(hash)
require.NoError(t, err, "hash should be valid base64")
_, err = base64.StdEncoding.DecodeString(salt)
require.NoError(t, err, "salt should be valid base64")

mw := BasicAuthWithArgon2Hash("dev", hash, salt)

ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("request %s", r.URL)
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("blah"))
require.NoError(t, err)
assert.True(t, IsAuthorized(r.Context()))
})))
defer ts.Close()

u := fmt.Sprintf("%s%s", ts.URL, "/something")
client := http.Client{Timeout: 5 * time.Second}

tests := []struct {
name string
username string
password string
expectedStatus int
}{
{
name: "no auth provided",
username: "",
password: "",
expectedStatus: http.StatusUnauthorized,
},
{
name: "correct credentials",
username: "dev",
password: "good",
expectedStatus: http.StatusOK,
},
{
name: "wrong username",
username: "wrong",
password: "good",
expectedStatus: http.StatusForbidden,
},
{
name: "wrong password",
username: "dev",
password: "bad",
expectedStatus: http.StatusForbidden,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest("GET", u, http.NoBody)
require.NoError(t, err)

if tc.username != "" || tc.password != "" {
req.SetBasicAuth(tc.username, tc.password)
}

resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
})
}
}

func TestHashGenerationFunctions(t *testing.T) {
t.Run("bcrypt hash generation", func(t *testing.T) {
hash, err := GenerateBcryptHash("testpassword")
require.NoError(t, err)
require.NotEmpty(t, hash)

err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("testpassword"))
require.NoError(t, err)
})

t.Run("argon2 hash generation", func(t *testing.T) {
hash, salt, err := GenerateArgon2Hash("testpassword")
require.NoError(t, err)
require.NotEmpty(t, hash)
require.NotEmpty(t, salt)

// verify the values are valid base64
hashBytes, err := base64.StdEncoding.DecodeString(hash)
require.NoError(t, err, "hash should be valid base64")
saltBytes, err := base64.StdEncoding.DecodeString(salt)
require.NoError(t, err, "salt should be valid base64")

// verify the hash works
newHash := argon2.IDKey([]byte("testpassword"), saltBytes, 1, 64*1024, 4, 32)
require.Equal(t, hashBytes, newHash)

// test with wrong password
wrongHash := argon2.IDKey([]byte("wrongpassword"), saltBytes, 1, 64*1024, 4, 32)
require.NotEqual(t, hashBytes, wrongHash)
})
}

func TestArgon2InvalidInputs(t *testing.T) {
t.Run("invalid base64 salt", func(t *testing.T) {
mw := BasicAuthWithArgon2Hash("dev", "validbase64==", "invalid-base64")
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called with invalid base64")
})))
defer ts.Close()

req, err := http.NewRequest("GET", ts.URL, http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("dev", "password")

client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})

t.Run("invalid base64 hash", func(t *testing.T) {
mw := BasicAuthWithArgon2Hash("dev", "invalid-base64", "validbase64==")
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called with invalid base64")
})))
defer ts.Close()

req, err := http.NewRequest("GET", ts.URL, http.NoBody)
require.NoError(t, err)
req.SetBasicAuth("dev", "password")

client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
})
}

0 comments on commit 9738b8e

Please sign in to comment.