Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hashed auth #32

Merged
merged 7 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest

steps:
- name: set up go 1.21
- name: set up go 1.23
uses: actions/setup-go@v3
with:
go-version: "1.21"
go-version: "1.23"
id: go

- name: checkout
Expand All @@ -33,11 +33,11 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
version: v1.61

- name: install goveralls
run: |
GO111MODULE=off go get -u -v github.com/mattn/goveralls
go install github.com/mattn/goveralls@latest

- name: submit coverage
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
Expand Down
32 changes: 21 additions & 11 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
run:
timeout: 5m
tests: false

linters-settings:
govet:
enable:
- shadow
gocyclo:
min-complexity: 15
dupl:
threshold: 100
goconst:
min-len: 2
min-occurrences: 2
Expand All @@ -20,37 +20,47 @@ linters-settings:
- experimental
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
- singleCaseSwitch
- ifElseChain

linters:
enable:
- staticcheck
- gosimple
- revive
- govet
- unconvert
- staticcheck
- unused
- gosec
- gocyclo
- dupl
- misspell
- unparam
- typecheck
- ineffassign
- stylecheck
- gochecknoinits
- exportloopref
- copyloopvar
- gocritic
- nakedret
- gosimple
- prealloc
fast: false
disable-all: true

issues:
exclude-dirs:
- vendor
exclude-rules:
- text: 'Deferring unsafe method "Close" on type "io.ReadCloser"'
- text: "at least one file in a package should have a package comment"
linters:
- gosec
- stylecheck
- text: "should have a package comment"
linters:
- revive
- path: _test\.go
linters:
- gosec
- dupl
exclude-use-default: false
exclude-use-default: false

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, 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
}
Loading
Loading