Skip to content

Commit 2e544ae

Browse files
authored
🔥 feat: Support hashed BasicAuth passwords (#3631)
1 parent 1d9eca3 commit 2e544ae

File tree

4 files changed

+256
-28
lines changed

4 files changed

+256
-28
lines changed

docs/middleware/basicauth.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,25 @@ After you initiate your Fiber app, you can use the following possibilities:
3333
// Provide a minimal config
3434
app.Use(basicauth.New(basicauth.Config{
3535
Users: map[string]string{
36-
"john": "doe",
37-
"admin": "123456",
36+
// "doe" hashed using SHA-256
37+
"john": "{SHA256}eZ75KhGvkY4/t0HfQpNPO1aO0tk6wd908bjUGieTKm8=",
38+
// "123456" hashed using bcrypt
39+
"admin": "$2a$10$gTYwCN66/tBRoCr3.TXa1.v1iyvwIF7GRBqxzv7G.AHLMt/owXrp.",
3840
},
3941
}))
4042

4143
// Or extend your config for customization
4244
app.Use(basicauth.New(basicauth.Config{
4345
Users: map[string]string{
44-
"john": "doe",
45-
"admin": "123456",
46+
// "doe" hashed using SHA-256
47+
"john": "{SHA256}eZ75KhGvkY4/t0HfQpNPO1aO0tk6wd908bjUGieTKm8=",
48+
// "123456" hashed using bcrypt
49+
"admin": "$2a$10$gTYwCN66/tBRoCr3.TXa1.v1iyvwIF7GRBqxzv7G.AHLMt/owXrp.",
4650
},
4751
Realm: "Forbidden",
4852
Authorizer: func(user, pass string, c fiber.Ctx) bool {
49-
if user == "john" && pass == "doe" {
50-
return true
51-
}
52-
if user == "admin" && pass == "123456" {
53-
return true
54-
}
55-
return false
53+
// custom validation logic
54+
return (user == "john" || user == "admin")
5655
},
5756
Unauthorized: func(c fiber.Ctx) error {
5857
return c.SendFile("./unauthorized.html")
@@ -62,6 +61,18 @@ app.Use(basicauth.New(basicauth.Config{
6261

6362
Getting the username and password
6463

64+
### Password hashes
65+
66+
Passwords must be supplied in pre-hashed form. The middleware detects the
67+
hashing algorithm from a prefix:
68+
69+
- `"{SHA512}"`, `"{SHA256}"`, or `"{SHA}"` followed by a base64 encoded digest
70+
- `"{MD5}"` followed by a base64 encoded digest
71+
- standard bcrypt strings beginning with `$2`
72+
73+
If no prefix is present the value is interpreted as a SHA-256 digest encoded in
74+
hex or base64. Plaintext passwords are rejected.
75+
6576
```go
6677
func handler(c fiber.Ctx) error {
6778
username := basicauth.UsernameFromContext(c)
@@ -76,7 +87,7 @@ func handler(c fiber.Ctx) error {
7687
| Property | Type | Description | Default |
7788
|:----------------|:----------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------|
7889
| Next | `func(fiber.Ctx) bool` | Next defines a function to skip this middleware when returned true. | `nil` |
79-
| Users | `map[string]string` | Users defines the allowed credentials. | `map[string]string{}` |
90+
| Users | `map[string]string` | Users maps usernames to **hashed** passwords (e.g. bcrypt, `{SHA256}`). | `map[string]string{}` |
8091
| Realm | `string` | Realm is a string to define the realm attribute of BasicAuth. The realm identifies the system to authenticate against and can be used by clients to save credentials. | `"Restricted"` |
8192
| Charset | `string` | Charset sent in the `WWW-Authenticate` header, so clients know how credentials are encoded. | `"UTF-8"` |
8293
| HeaderLimit | `int` | Maximum allowed length of the `Authorization` header. Requests exceeding this limit are rejected. | `8192` |

docs/whats_new.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,7 @@ The adaptor middleware has been significantly optimized for performance and effi
10541054

10551055
### BasicAuth
10561056

1057-
The BasicAuth middleware now validates the `Authorization` header more rigorously and sets security-focused response headers. The default challenge includes the `charset="UTF-8"` parameter and disables caching. Passwords are no longer stored in the request context by default; use the new `StorePassword` option to retain them. A `Charset` option controls the value used in the challenge header.
1057+
The BasicAuth middleware now validates the `Authorization` header more rigorously and sets security-focused response headers. Passwords must be provided in **hashed** form (e.g. SHA-256 or bcrypt) rather than plaintext. The default challenge includes the `charset="UTF-8"` parameter and disables caching. Passwords are no longer stored in the request context by default; use the new `StorePassword` option to retain them. A `Charset` option controls the value used in the challenge header.
10581058
A new `HeaderLimit` option restricts the maximum length of the `Authorization` header (default: `8192` bytes).
10591059
The `Authorizer` function now receives the current `fiber.Ctx` as a third argument, allowing credential checks to incorporate request context.
10601060

@@ -1947,6 +1947,8 @@ Authorizer: func(user, pass string, _ fiber.Ctx) bool {
19471947
}
19481948
```
19491949
1950+
Passwords configured for BasicAuth must now be pre-hashed. If no prefix is supplied the middleware expects a SHA-256 digest encoded in hex. Common prefixes like `{SHA256}`, `{SHA}`, `{SHA512}`, `{MD5}` and bcrypt strings are also supported. Plaintext passwords are no longer accepted.
1951+
19501952
You can also set the optional `HeaderLimit`, `StorePassword`, and `Charset`
19511953
options to further control authentication behavior.
19521954

middleware/basicauth/basicauth_test.go

Lines changed: 150 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package basicauth
22

33
import (
4+
"crypto/md5" // #nosec G501 - test compatibility
5+
"crypto/sha1" // #nosec G505 - test compatibility
6+
"crypto/sha256"
7+
"crypto/sha512"
48
"encoding/base64"
9+
"encoding/hex"
510
"fmt"
611
"io"
712
"net/http/httptest"
@@ -10,8 +15,29 @@ import (
1015
"github.com/gofiber/fiber/v3"
1116
"github.com/stretchr/testify/require"
1217
"github.com/valyala/fasthttp"
18+
"golang.org/x/crypto/bcrypt"
1319
)
1420

21+
func sha256Hash(p string) string {
22+
sum := sha256.Sum256([]byte(p))
23+
return "{SHA256}" + base64.StdEncoding.EncodeToString(sum[:])
24+
}
25+
26+
func sha512Hash(p string) string {
27+
sum := sha512.Sum512([]byte(p))
28+
return "{SHA512}" + base64.StdEncoding.EncodeToString(sum[:])
29+
}
30+
31+
func sha1Hash(p string) string {
32+
sum := sha1.Sum([]byte(p)) // #nosec G401 - test compatibility
33+
return "{SHA}" + base64.StdEncoding.EncodeToString(sum[:])
34+
}
35+
36+
func md5Hash(p string) string {
37+
sum := md5.Sum([]byte(p)) // #nosec G401 - test compatibility
38+
return "{MD5}" + base64.StdEncoding.EncodeToString(sum[:])
39+
}
40+
1541
// go test -run Test_BasicAuth_Next
1642
func Test_BasicAuth_Next(t *testing.T) {
1743
t.Parallel()
@@ -31,10 +57,14 @@ func Test_Middleware_BasicAuth(t *testing.T) {
3157
t.Parallel()
3258
app := fiber.New()
3359

60+
hashedJohn := sha256Hash("doe")
61+
hashedAdmin, err := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
62+
require.NoError(t, err)
63+
3464
app.Use(New(Config{
3565
Users: map[string]string{
36-
"john": "doe",
37-
"admin": "123456",
66+
"john": hashedJohn,
67+
"admin": string(hashedAdmin),
3868
},
3969
StorePassword: true,
4070
}))
@@ -96,8 +126,10 @@ func Test_BasicAuth_NoStorePassword(t *testing.T) {
96126
t.Parallel()
97127
app := fiber.New()
98128

129+
hashedJohn := sha256Hash("doe")
130+
99131
app.Use(New(Config{
100-
Users: map[string]string{"john": "doe"},
132+
Users: map[string]string{"john": hashedJohn},
101133
}))
102134

103135
app.Get("/", func(c fiber.Ctx) error {
@@ -143,7 +175,8 @@ func Test_BasicAuth_WWWAuthenticateHeader(t *testing.T) {
143175
t.Parallel()
144176
app := fiber.New()
145177

146-
app.Use(New(Config{Users: map[string]string{"john": "doe"}}))
178+
hashedJohn := sha256Hash("doe")
179+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}}))
147180

148181
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", nil))
149182
require.NoError(t, err)
@@ -155,7 +188,8 @@ func Test_BasicAuth_InvalidHeader(t *testing.T) {
155188
t.Parallel()
156189
app := fiber.New()
157190

158-
app.Use(New(Config{Users: map[string]string{"john": "doe"}}))
191+
hashedJohn := sha256Hash("doe")
192+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}}))
159193

160194
req := httptest.NewRequest(fiber.MethodGet, "/", nil)
161195
req.Header.Set(fiber.HeaderAuthorization, "Basic notbase64")
@@ -169,7 +203,8 @@ func Test_BasicAuth_EmptyAuthorization(t *testing.T) {
169203
t.Parallel()
170204
app := fiber.New()
171205

172-
app.Use(New(Config{Users: map[string]string{"john": "doe"}}))
206+
hashedJohn := sha256Hash("doe")
207+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}}))
173208

174209
cases := []string{"", " "}
175210
for _, h := range cases {
@@ -185,7 +220,8 @@ func Test_BasicAuth_WhitespaceHandling(t *testing.T) {
185220
t.Parallel()
186221
app := fiber.New()
187222

188-
app.Use(New(Config{Users: map[string]string{"john": "doe"}}))
223+
hashedJohn := sha256Hash("doe")
224+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}}))
189225
app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
190226

191227
creds := base64.StdEncoding.EncodeToString([]byte("john:doe"))
@@ -209,11 +245,12 @@ func Test_BasicAuth_WhitespaceHandling(t *testing.T) {
209245
func Test_BasicAuth_HeaderLimit(t *testing.T) {
210246
t.Parallel()
211247
creds := base64.StdEncoding.EncodeToString([]byte("john:doe"))
248+
hashedJohn := sha256Hash("doe")
212249

213250
t.Run("too large", func(t *testing.T) {
214251
t.Parallel()
215252
app := fiber.New()
216-
app.Use(New(Config{Users: map[string]string{"john": "doe"}, HeaderLimit: 10}))
253+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}, HeaderLimit: 10}))
217254
req := httptest.NewRequest(fiber.MethodGet, "/", nil)
218255
req.Header.Set(fiber.HeaderAuthorization, "Basic "+creds)
219256
resp, err := app.Test(req)
@@ -224,7 +261,7 @@ func Test_BasicAuth_HeaderLimit(t *testing.T) {
224261
t.Run("allowed", func(t *testing.T) {
225262
t.Parallel()
226263
app := fiber.New()
227-
app.Use(New(Config{Users: map[string]string{"john": "doe"}, HeaderLimit: 100}))
264+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}, HeaderLimit: 100}))
228265
app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
229266
req := httptest.NewRequest(fiber.MethodGet, "/", nil)
230267
req.Header.Set(fiber.HeaderAuthorization, "Basic "+creds)
@@ -238,9 +275,11 @@ func Test_BasicAuth_HeaderLimit(t *testing.T) {
238275
func Benchmark_Middleware_BasicAuth(b *testing.B) {
239276
app := fiber.New()
240277

278+
hashedJohn := sha256Hash("doe")
279+
241280
app.Use(New(Config{
242281
Users: map[string]string{
243-
"john": "doe",
282+
"john": hashedJohn,
244283
},
245284
}))
246285
app.Get("/", func(c fiber.Ctx) error {
@@ -267,9 +306,11 @@ func Benchmark_Middleware_BasicAuth(b *testing.B) {
267306
func Benchmark_Middleware_BasicAuth_Upper(b *testing.B) {
268307
app := fiber.New()
269308

309+
hashedJohn := sha256Hash("doe")
310+
270311
app.Use(New(Config{
271312
Users: map[string]string{
272-
"john": "doe",
313+
"john": hashedJohn,
273314
},
274315
}))
275316
app.Get("/", func(c fiber.Ctx) error {
@@ -296,7 +337,8 @@ func Test_BasicAuth_Immutable(t *testing.T) {
296337
t.Parallel()
297338
app := fiber.New(fiber.Config{Immutable: true})
298339

299-
app.Use(New(Config{Users: map[string]string{"john": "doe"}}))
340+
hashedJohn := sha256Hash("doe")
341+
app.Use(New(Config{Users: map[string]string{"john": hashedJohn}}))
300342
app.Get("/", func(c fiber.Ctx) error {
301343
return c.SendStatus(fiber.StatusTeapot)
302344
})
@@ -309,3 +351,99 @@ func Test_BasicAuth_Immutable(t *testing.T) {
309351
require.NoError(t, err)
310352
require.Equal(t, fiber.StatusTeapot, resp.StatusCode)
311353
}
354+
355+
func Test_parseHashedPassword(t *testing.T) {
356+
t.Parallel()
357+
pass := "secret"
358+
sha := sha256.Sum256([]byte(pass))
359+
b64 := base64.StdEncoding.EncodeToString(sha[:])
360+
hexDigest := hex.EncodeToString(sha[:])
361+
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.MinCost)
362+
require.NoError(t, err)
363+
364+
cases := []struct {
365+
name string
366+
hashed string
367+
}{
368+
{"bcrypt", string(bcryptHash)},
369+
{"sha512", sha512Hash(pass)},
370+
{"sha256", sha256Hash(pass)},
371+
{"sha256-hex", hexDigest},
372+
{"sha256-b64", b64},
373+
{"sha1", sha1Hash(pass)},
374+
{"md5", md5Hash(pass)},
375+
}
376+
377+
for _, tt := range cases {
378+
t.Run(tt.name, func(t *testing.T) {
379+
t.Parallel()
380+
verify, err := parseHashedPassword(tt.hashed)
381+
require.NoError(t, err)
382+
require.True(t, verify(pass))
383+
require.False(t, verify("wrong"))
384+
})
385+
}
386+
}
387+
388+
func Test_BasicAuth_HashVariants(t *testing.T) {
389+
t.Parallel()
390+
pass := "doe"
391+
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.MinCost)
392+
require.NoError(t, err)
393+
cases := []struct {
394+
name string
395+
hashed string
396+
}{
397+
{"bcrypt", string(bcryptHash)},
398+
{"sha512", sha512Hash(pass)},
399+
{"sha256", sha256Hash(pass)},
400+
{"sha256-hex", func() string { h := sha256.Sum256([]byte(pass)); return hex.EncodeToString(h[:]) }()},
401+
{"sha1", sha1Hash(pass)},
402+
{"md5", md5Hash(pass)},
403+
}
404+
405+
for _, tt := range cases {
406+
app := fiber.New()
407+
app.Use(New(Config{Users: map[string]string{"john": tt.hashed}}))
408+
app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
409+
410+
creds := base64.StdEncoding.EncodeToString([]byte("john:" + pass))
411+
req := httptest.NewRequest(fiber.MethodGet, "/", nil)
412+
req.Header.Set(fiber.HeaderAuthorization, "Basic "+creds)
413+
resp, err := app.Test(req)
414+
require.NoError(t, err)
415+
require.Equal(t, fiber.StatusTeapot, resp.StatusCode)
416+
}
417+
}
418+
419+
func Test_BasicAuth_HashVariants_Invalid(t *testing.T) {
420+
t.Parallel()
421+
pass := "doe"
422+
wrong := "wrong"
423+
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.MinCost)
424+
require.NoError(t, err)
425+
cases := []struct {
426+
name string
427+
hashed string
428+
}{
429+
{"bcrypt", string(bcryptHash)},
430+
{"sha512", sha512Hash(pass)},
431+
{"sha256", sha256Hash(pass)},
432+
{"sha256-hex", func() string { h := sha256.Sum256([]byte(pass)); return hex.EncodeToString(h[:]) }()},
433+
{"sha1", sha1Hash(pass)},
434+
{"md5", md5Hash(pass)},
435+
}
436+
437+
for _, tt := range cases {
438+
app := fiber.New()
439+
app.Use(New(Config{Users: map[string]string{"john": tt.hashed}}))
440+
app.Get("/", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
441+
442+
creds := base64.StdEncoding.EncodeToString([]byte("john:" + wrong))
443+
req := httptest.NewRequest(fiber.MethodGet, "/", nil)
444+
req.Header.Set(fiber.HeaderAuthorization, "Basic "+creds)
445+
resp, err := app.Test(req)
446+
require.NoError(t, err)
447+
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
448+
}
449+
}

0 commit comments

Comments
 (0)