Skip to content

Commit 9738b8e

Browse files
committed
add middlewares for auth with hashes
1 parent fbfa8cb commit 9738b8e

File tree

3 files changed

+333
-0
lines changed

3 files changed

+333
-0
lines changed

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,58 @@ example with chi router:
182182
router.Use(rest.Reject(http.StatusBadRequest, "X-Request-Id header is required", rejectFn))
183183
```
184184

185+
### BasicAuth middleware family
186+
187+
The package provides several BasicAuth middleware implementations for different authentication needs:
188+
189+
#### BasicAuth
190+
The base middleware that requires basic auth and matches user & passwd with a client-provided checker function.
191+
```go
192+
checkFn := func(user, passwd string) bool {
193+
return user == "admin" && passwd == "secret"
194+
}
195+
router.Use(rest.BasicAuth(checkFn))
196+
```
197+
198+
#### BasicAuthWithUserPasswd
199+
A simpler version comparing user & password with provided values directly.
200+
```go
201+
router.Use(rest.BasicAuthWithUserPasswd("admin", "secret"))
202+
```
203+
204+
#### BasicAuthWithBcryptHash
205+
Matches username and bcrypt-hashed password. Useful when storing hashed passwords.
206+
```go
207+
hash, err := rest.GenerateBcryptHash("secret")
208+
if err != nil {
209+
// handle error
210+
}
211+
router.Use(rest.BasicAuthWithBcryptHash("admin", hash))
212+
```
213+
214+
#### BasicAuthWithArgon2Hash
215+
Similar to bcrypt version but uses Argon2id hash with a separate salt. Both hash and salt are base64 encoded.
216+
```go
217+
hash, salt, err := rest.GenerateArgon2Hash("secret")
218+
if err != nil {
219+
// handle error
220+
}
221+
router.Use(rest.BasicAuthWithArgon2Hash("admin", hash, salt))
222+
```
223+
224+
#### BasicAuthWithPrompt
225+
Similar to BasicAuthWithUserPasswd but adds browser's authentication prompt by setting the WWW-Authenticate header.
226+
```go
227+
router.Use(rest.BasicAuthWithPrompt("admin", "secret"))
228+
```
229+
230+
All BasicAuth middlewares:
231+
- Return `StatusUnauthorized` (401) if no auth header provided
232+
- Return `StatusForbidden` (403) if credentials check failed
233+
- Add IsAuthorized flag to the request context, retrievable with `rest.IsAuthorized(r.Context())`
234+
- Use constant-time comparison to prevent timing attacks
235+
- Support secure password hashing with bcrypt and Argon2id
236+
185237
### Benchmarks middleware
186238

187239
Benchmarks middleware allows measuring the time of request handling, number of requests per second and report aggregated metrics.

basic_auth.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package rest
22

33
import (
44
"context"
5+
"crypto/rand"
56
"crypto/subtle"
7+
"encoding/base64"
68
"net/http"
9+
10+
"golang.org/x/crypto/argon2"
11+
"golang.org/x/crypto/bcrypt"
712
)
813

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

47+
// BasicAuthWithBcryptHash middleware requires basic auth and matches user & bcrypt hashed password
48+
func BasicAuthWithBcryptHash(user, hashedPassword string) func(http.Handler) http.Handler {
49+
checkFn := func(reqUser, reqPasswd string) bool {
50+
if reqUser != user {
51+
return false
52+
}
53+
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(reqPasswd))
54+
return err == nil
55+
}
56+
return BasicAuth(checkFn)
57+
}
58+
59+
// BasicAuthWithArgon2Hash middleware requires basic auth and matches user & argon2 hashed password
60+
// both hashedPassword and salt must be base64 encoded strings
61+
// Uses Argon2id with parameters: t=1, m=64*1024 KB, p=4 threads
62+
func BasicAuthWithArgon2Hash(user, hashedPassword, salt string) func(http.Handler) http.Handler {
63+
checkFn := func(reqUser, reqPasswd string) bool {
64+
if reqUser != user {
65+
return false
66+
}
67+
68+
saltBytes, err := base64.StdEncoding.DecodeString(salt)
69+
if err != nil {
70+
return false
71+
}
72+
storedHashBytes, err := base64.StdEncoding.DecodeString(hashedPassword)
73+
if err != nil {
74+
return false
75+
}
76+
77+
hash := argon2.IDKey([]byte(reqPasswd), saltBytes, 1, 64*1024, 4, 32)
78+
return subtle.ConstantTimeCompare(hash, storedHashBytes) == 1
79+
}
80+
return BasicAuth(checkFn)
81+
}
82+
4283
// IsAuthorized returns true is user authorized.
4384
// it can be used in handlers to check if BasicAuth middleware was applied
4485
func IsAuthorized(ctx context.Context) bool {
@@ -71,3 +112,25 @@ func BasicAuthWithPrompt(user, passwd string) func(http.Handler) http.Handler {
71112
return http.HandlerFunc(fn)
72113
}
73114
}
115+
116+
// GenerateBcryptHash generates a bcrypt hash from a password
117+
func GenerateBcryptHash(password string) (string, error) {
118+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
119+
if err != nil {
120+
return "", err
121+
}
122+
return string(hash), nil
123+
}
124+
125+
// GenerateArgon2Hash generates an argon2 hash and salt from a password
126+
func GenerateArgon2Hash(password string) (hash string, salt string, err error) {
127+
saltBytes := make([]byte, 16)
128+
if _, err := rand.Read(saltBytes); err != nil {
129+
return "", "", err
130+
}
131+
132+
// using recommended parameters: time=1, memory=64*1024, threads=4, keyLen=32
133+
hashBytes := argon2.IDKey([]byte(password), saltBytes, 1, 64*1024, 4, 32)
134+
135+
return base64.StdEncoding.EncodeToString(hashBytes), base64.StdEncoding.EncodeToString(saltBytes), nil
136+
}

basic_auth_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package rest
22

33
import (
4+
"encoding/base64"
45
"fmt"
56
"net/http"
67
"net/http/httptest"
@@ -9,6 +10,8 @@ import (
910

1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
13+
"golang.org/x/crypto/argon2"
14+
"golang.org/x/crypto/bcrypt"
1215
)
1316

1417
func TestBasicAuth(t *testing.T) {
@@ -142,3 +145,218 @@ func TestBasicAuthWithPrompt(t *testing.T) {
142145
assert.Equal(t, `Basic realm="restricted", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
143146
}
144147
}
148+
149+
func TestBasicAuthWithHash(t *testing.T) {
150+
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("good"), bcrypt.MinCost)
151+
require.NoError(t, err)
152+
t.Logf("hashed password: %s", string(hashedPassword))
153+
154+
mw := BasicAuthWithBcryptHash("dev", string(hashedPassword))
155+
156+
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157+
t.Logf("request %s", r.URL)
158+
w.WriteHeader(http.StatusOK)
159+
_, err := w.Write([]byte("blah"))
160+
require.NoError(t, err)
161+
assert.True(t, IsAuthorized(r.Context()))
162+
})))
163+
defer ts.Close()
164+
165+
u := fmt.Sprintf("%s%s", ts.URL, "/something")
166+
client := http.Client{Timeout: 5 * time.Second}
167+
168+
tests := []struct {
169+
name string
170+
username string
171+
password string
172+
expectedStatus int
173+
}{
174+
{
175+
name: "no auth provided",
176+
username: "",
177+
password: "",
178+
expectedStatus: http.StatusUnauthorized,
179+
},
180+
{
181+
name: "correct credentials",
182+
username: "dev",
183+
password: "good",
184+
expectedStatus: http.StatusOK,
185+
},
186+
{
187+
name: "wrong username",
188+
username: "wrong",
189+
password: "good",
190+
expectedStatus: http.StatusForbidden,
191+
},
192+
{
193+
name: "wrong password",
194+
username: "dev",
195+
password: "bad",
196+
expectedStatus: http.StatusForbidden,
197+
},
198+
{
199+
name: "empty password",
200+
username: "dev",
201+
password: "",
202+
expectedStatus: http.StatusForbidden,
203+
},
204+
}
205+
206+
for _, tc := range tests {
207+
t.Run(tc.name, func(t *testing.T) {
208+
req, err := http.NewRequest("GET", u, http.NoBody)
209+
require.NoError(t, err)
210+
211+
if tc.username != "" || tc.password != "" {
212+
req.SetBasicAuth(tc.username, tc.password)
213+
}
214+
215+
resp, err := client.Do(req)
216+
require.NoError(t, err)
217+
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
218+
})
219+
}
220+
}
221+
222+
func TestBasicAuthWithArgon2Hash(t *testing.T) {
223+
password := "good"
224+
hash, salt, err := GenerateArgon2Hash(password)
225+
require.NoError(t, err)
226+
t.Logf("hash: %s, salt: %s", hash, salt)
227+
228+
// verify the returned values are valid base64
229+
_, err = base64.StdEncoding.DecodeString(hash)
230+
require.NoError(t, err, "hash should be valid base64")
231+
_, err = base64.StdEncoding.DecodeString(salt)
232+
require.NoError(t, err, "salt should be valid base64")
233+
234+
mw := BasicAuthWithArgon2Hash("dev", hash, salt)
235+
236+
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237+
t.Logf("request %s", r.URL)
238+
w.WriteHeader(http.StatusOK)
239+
_, err := w.Write([]byte("blah"))
240+
require.NoError(t, err)
241+
assert.True(t, IsAuthorized(r.Context()))
242+
})))
243+
defer ts.Close()
244+
245+
u := fmt.Sprintf("%s%s", ts.URL, "/something")
246+
client := http.Client{Timeout: 5 * time.Second}
247+
248+
tests := []struct {
249+
name string
250+
username string
251+
password string
252+
expectedStatus int
253+
}{
254+
{
255+
name: "no auth provided",
256+
username: "",
257+
password: "",
258+
expectedStatus: http.StatusUnauthorized,
259+
},
260+
{
261+
name: "correct credentials",
262+
username: "dev",
263+
password: "good",
264+
expectedStatus: http.StatusOK,
265+
},
266+
{
267+
name: "wrong username",
268+
username: "wrong",
269+
password: "good",
270+
expectedStatus: http.StatusForbidden,
271+
},
272+
{
273+
name: "wrong password",
274+
username: "dev",
275+
password: "bad",
276+
expectedStatus: http.StatusForbidden,
277+
},
278+
}
279+
280+
for _, tc := range tests {
281+
t.Run(tc.name, func(t *testing.T) {
282+
req, err := http.NewRequest("GET", u, http.NoBody)
283+
require.NoError(t, err)
284+
285+
if tc.username != "" || tc.password != "" {
286+
req.SetBasicAuth(tc.username, tc.password)
287+
}
288+
289+
resp, err := client.Do(req)
290+
require.NoError(t, err)
291+
assert.Equal(t, tc.expectedStatus, resp.StatusCode)
292+
})
293+
}
294+
}
295+
296+
func TestHashGenerationFunctions(t *testing.T) {
297+
t.Run("bcrypt hash generation", func(t *testing.T) {
298+
hash, err := GenerateBcryptHash("testpassword")
299+
require.NoError(t, err)
300+
require.NotEmpty(t, hash)
301+
302+
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("testpassword"))
303+
require.NoError(t, err)
304+
})
305+
306+
t.Run("argon2 hash generation", func(t *testing.T) {
307+
hash, salt, err := GenerateArgon2Hash("testpassword")
308+
require.NoError(t, err)
309+
require.NotEmpty(t, hash)
310+
require.NotEmpty(t, salt)
311+
312+
// verify the values are valid base64
313+
hashBytes, err := base64.StdEncoding.DecodeString(hash)
314+
require.NoError(t, err, "hash should be valid base64")
315+
saltBytes, err := base64.StdEncoding.DecodeString(salt)
316+
require.NoError(t, err, "salt should be valid base64")
317+
318+
// verify the hash works
319+
newHash := argon2.IDKey([]byte("testpassword"), saltBytes, 1, 64*1024, 4, 32)
320+
require.Equal(t, hashBytes, newHash)
321+
322+
// test with wrong password
323+
wrongHash := argon2.IDKey([]byte("wrongpassword"), saltBytes, 1, 64*1024, 4, 32)
324+
require.NotEqual(t, hashBytes, wrongHash)
325+
})
326+
}
327+
328+
func TestArgon2InvalidInputs(t *testing.T) {
329+
t.Run("invalid base64 salt", func(t *testing.T) {
330+
mw := BasicAuthWithArgon2Hash("dev", "validbase64==", "invalid-base64")
331+
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332+
t.Error("Handler should not be called with invalid base64")
333+
})))
334+
defer ts.Close()
335+
336+
req, err := http.NewRequest("GET", ts.URL, http.NoBody)
337+
require.NoError(t, err)
338+
req.SetBasicAuth("dev", "password")
339+
340+
client := http.Client{Timeout: 5 * time.Second}
341+
resp, err := client.Do(req)
342+
require.NoError(t, err)
343+
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
344+
})
345+
346+
t.Run("invalid base64 hash", func(t *testing.T) {
347+
mw := BasicAuthWithArgon2Hash("dev", "invalid-base64", "validbase64==")
348+
ts := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
349+
t.Error("Handler should not be called with invalid base64")
350+
})))
351+
defer ts.Close()
352+
353+
req, err := http.NewRequest("GET", ts.URL, http.NoBody)
354+
require.NoError(t, err)
355+
req.SetBasicAuth("dev", "password")
356+
357+
client := http.Client{Timeout: 5 * time.Second}
358+
resp, err := client.Do(req)
359+
require.NoError(t, err)
360+
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
361+
})
362+
}

0 commit comments

Comments
 (0)