Skip to content

Commit e37e3a1

Browse files
committed
auth: add www-authenticate header #278
1 parent 88748eb commit e37e3a1

File tree

6 files changed

+149
-3
lines changed

6 files changed

+149
-3
lines changed

auth/authenticator.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
67

78
"goyave.dev/goyave/v5"
@@ -11,6 +12,8 @@ import (
1112
// if this meta is present in the matched route or any of its parent and is equal to `true`.
1213
const MetaAuth = "goyave.require-auth"
1314

15+
const defaultRealm = "Authorization required"
16+
1417
// Authenticator is an object in charge of authenticating a client.
1518
//
1619
// The generic type should be a DTO and not be a pointer. The `request.User`
@@ -31,6 +34,16 @@ type Authenticator[T any] interface {
3134
Authenticate(request *goyave.Request) (*T, error)
3235
}
3336

37+
// SchemeAuthenticator let Authenticators indicate their scheme for use in the WWW-Authenticate
38+
// header returned when authentication fails.
39+
// The header is not returned if the authenticator doesn't implement this interface.
40+
type SchemeAuthenticator interface {
41+
// Scheme returns the authenticator's scheme.
42+
// The returned value should be one of the values in the IANA's HTTP Authentication Schemes list.
43+
// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
44+
Scheme() string
45+
}
46+
3447
// UserService is the dependency of authenticators used to retrieve a user by its "username".
3548
//
3649
// A username is actually any identifier (an ID, a email, a name, etc). It is the responsibility
@@ -56,13 +69,21 @@ type Unauthorizer interface {
5669
// The T parameter represents the user DTO and should not be a pointer.
5770
type Handler[T any] struct {
5871
Authenticator[T]
72+
73+
// Realm describes the protected area. It is returned in the
74+
// `WWW-Authenticate` header on authentication failure if the
75+
// Authenticator implements `SchemeAuthenticator`.
76+
// If empty, "Authorization Required" will be used by default.
77+
Realm string
5978
}
6079

6180
// Handle on success, set the request's `User` to the user returned by the authenticator
6281
// and inject it in the request's `context.Context`. The user can be retrieved from the
6382
// context using `UserFromContext`.
6483
//
6584
// Blocks if the authentication is not successful.
85+
// If the authenticator implements `SchemeAuthenticator`, add the `WWW-Authenticate` header
86+
// to the response.
6687
// If the authenticator implements `Unauthorizer`, `OnUnauthorized` is called,
6788
// otherwise returns a default `401 Unauthorized` error.
6889
// If the matched route doesn't contain the `MetaAuth` or if it's not equal to `true`,
@@ -76,6 +97,9 @@ func (m *Handler[T]) Handle(next goyave.Handler) goyave.Handler {
7697

7798
user, err := m.Authenticate(request)
7899
if err != nil {
100+
if authenticateHeader := m.getAuthenticateHeader(); authenticateHeader != "" {
101+
response.Header().Set("WWW-Authenticate", authenticateHeader)
102+
}
79103
if unauthorizer, ok := m.Authenticator.(Unauthorizer); ok {
80104
unauthorizer.OnUnauthorized(response, request, err)
81105
return
@@ -89,6 +113,14 @@ func (m *Handler[T]) Handle(next goyave.Handler) goyave.Handler {
89113
}
90114
}
91115

116+
func (m *Handler[T]) getAuthenticateHeader() string {
117+
sa, ok := m.Authenticator.(SchemeAuthenticator)
118+
if !ok {
119+
return ""
120+
}
121+
return fmt.Sprintf(`%s realm="%s", charset="UTF-8"`, sa.Scheme(), m.Realm)
122+
}
123+
92124
// Middleware returns an authentication middleware which will use the given
93125
// authenticator and set the request's `User` according to the generic type `T`, which
94126
// should be a DTO.
@@ -98,8 +130,18 @@ func (m *Handler[T]) Handle(next goyave.Handler) goyave.Handler {
98130
// If the matched route or any of its parent doesn't have this meta or if it's not equal to
99131
// `true`, the authentication is skipped.
100132
func Middleware[T any](authenticator Authenticator[T]) *Handler[T] {
133+
return MiddlewareWithRealm(authenticator, defaultRealm)
134+
}
135+
136+
// MiddlewareWithRealm is the same as `Middleware` but with a custom realm description.
137+
// The realm describes the protected area and is returned in the `WWW-Authenticate` header
138+
// when the authentication fails.
139+
// Note that the `WWW-Authenticate` header is NOT added to the response if the authenticator
140+
// doesn't implement `SchemeAuthenticator`.
141+
func MiddlewareWithRealm[T any](authenticator Authenticator[T], realm string) *Handler[T] {
101142
return &Handler[T]{
102143
Authenticator: authenticator,
144+
Realm: realm,
103145
}
104146
}
105147

auth/authenticator_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ func (a *TestBasicUnauthorizer) OnUnauthorized(response *goyave.Response, _ *goy
4040
response.JSON(http.StatusUnauthorized, map[string]string{"custom error key": err.Error()})
4141
}
4242

43+
type TestNoScheme struct {
44+
goyave.Component
45+
}
46+
47+
func (a *TestNoScheme) Authenticate(request *goyave.Request) (*BasicUser, error) {
48+
return (&ConfigBasicAuthenticator{Component: a.Component}).Authenticate(request)
49+
}
50+
4351
func prepareAuthenticatorTest(t *testing.T) (*testutil.TestServer, *TestUser) {
4452
cfg := config.LoadDefault()
4553
cfg.Set("database.connection", "sqlite3")
@@ -64,6 +72,7 @@ func TestAuthenticator(t *testing.T) {
6472

6573
mockUserService := &MockUserService[TestUser]{user: user}
6674
authenticator := Middleware(NewBasicAuthenticator(mockUserService, "Password"))
75+
assert.Equal(t, defaultRealm, authenticator.Realm)
6776

6877
request := server.NewTestRequest(http.MethodGet, "/protected", nil)
6978
request.Request().SetBasicAuth(user.Email, "secret")
@@ -77,6 +86,7 @@ func TestAuthenticator(t *testing.T) {
7786
})
7887
assert.Equal(t, http.StatusOK, resp.StatusCode)
7988
assert.NoError(t, resp.Body.Close())
89+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
8090

8191
request = server.NewTestRequest(http.MethodGet, "/protected", nil)
8292
request.Request().SetBasicAuth(user.Email, "incorrect password")
@@ -89,6 +99,7 @@ func TestAuthenticator(t *testing.T) {
8999
assert.NoError(t, resp.Body.Close())
90100
require.NoError(t, err)
91101
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
102+
assert.Equal(t, `Basic realm="Authorization required", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
92103
})
93104

94105
t.Run("NoAuth", func(t *testing.T) {
@@ -108,6 +119,7 @@ func TestAuthenticator(t *testing.T) {
108119
})
109120
assert.Equal(t, http.StatusOK, resp.StatusCode)
110121
assert.NoError(t, resp.Body.Close())
122+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
111123

112124
request.Route = &goyave.Route{Meta: map[string]any{}}
113125
resp = server.TestMiddleware(authenticator, request, func(response *goyave.Response, request *goyave.Request) {
@@ -116,6 +128,7 @@ func TestAuthenticator(t *testing.T) {
116128
})
117129
assert.Equal(t, http.StatusOK, resp.StatusCode)
118130
assert.NoError(t, resp.Body.Close())
131+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
119132
})
120133

121134
t.Run("MiddlewareUnauthorizer", func(t *testing.T) {
@@ -136,6 +149,51 @@ func TestAuthenticator(t *testing.T) {
136149
assert.NoError(t, resp.Body.Close())
137150
require.NoError(t, err)
138151
assert.Equal(t, map[string]string{"custom error key": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
152+
assert.Equal(t, `Basic realm="Authorization required", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
153+
})
154+
155+
t.Run("MiddlewareWithRealm", func(t *testing.T) {
156+
server, user := prepareAuthenticatorTest(t)
157+
t.Cleanup(func() { server.CloseDB() })
158+
159+
mockUserService := &MockUserService[TestUser]{user: user}
160+
authenticator := MiddlewareWithRealm(&TestBasicUnauthorizer{BasicAuthenticator: NewBasicAuthenticator(mockUserService, "Password")}, "custom realm")
161+
162+
request := server.NewTestRequest(http.MethodGet, "/protected", nil)
163+
request.Request().SetBasicAuth(user.Email, "incorrect password")
164+
request.Route = &goyave.Route{Meta: map[string]any{MetaAuth: true}}
165+
resp := server.TestMiddleware(authenticator, request, func(response *goyave.Response, _ *goyave.Request) {
166+
response.Status(http.StatusOK)
167+
})
168+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
169+
body, err := testutil.ReadJSONBody[map[string]string](resp.Body)
170+
assert.NoError(t, resp.Body.Close())
171+
require.NoError(t, err)
172+
assert.Equal(t, map[string]string{"custom error key": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
173+
assert.Equal(t, `Basic realm="custom realm", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
174+
})
175+
176+
t.Run("MiddlewareWithRealmNoScheme", func(t *testing.T) {
177+
server, user := prepareAuthenticatorTest(t)
178+
t.Cleanup(func() { server.CloseDB() })
179+
180+
server.Config().Set("auth.basic.username", "johndoe")
181+
server.Config().Set("auth.basic.password", "secret")
182+
183+
authenticator := MiddlewareWithRealm(&TestNoScheme{}, "custom realm")
184+
185+
request := server.NewTestRequest(http.MethodGet, "/protected", nil)
186+
request.Request().SetBasicAuth(user.Email, "incorrect password")
187+
request.Route = &goyave.Route{Meta: map[string]any{MetaAuth: true}}
188+
resp := server.TestMiddleware(authenticator, request, func(response *goyave.Response, _ *goyave.Request) {
189+
response.Status(http.StatusOK)
190+
})
191+
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
192+
body, err := testutil.ReadJSONBody[map[string]string](resp.Body)
193+
assert.NoError(t, resp.Body.Close())
194+
require.NoError(t, err)
195+
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
196+
assert.Empty(t, resp.Header.Get("WWW-Authenticate")) // Authentication doesn't implement SchemeAuthenticator, header should not be added
139197
})
140198
}
141199

auth/basic.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ func (a *BasicAuthenticator[T]) Authenticate(request *goyave.Request) (*T, error
8484
return user, nil
8585
}
8686

87+
func (a *BasicAuthenticator[T]) Scheme() string {
88+
return "Basic"
89+
}
90+
8791
//--------------------------------------------
8892

8993
func init() {
@@ -131,6 +135,10 @@ func (a *ConfigBasicAuthenticator) Authenticate(request *goyave.Request) (*Basic
131135
}, nil
132136
}
133137

138+
func (a *ConfigBasicAuthenticator) Scheme() string {
139+
return "Basic"
140+
}
141+
134142
// ConfigBasicAuth create a new authenticator middleware for
135143
// config-based Basic authentication. On auth success, the request
136144
// user is set to a `*BasicUser`.
@@ -139,3 +147,10 @@ func (a *ConfigBasicAuthenticator) Authenticate(request *goyave.Request) (*Basic
139147
func ConfigBasicAuth() *Handler[BasicUser] {
140148
return Middleware(&ConfigBasicAuthenticator{})
141149
}
150+
151+
// ConfigBasicAuthWithRealm is the same as ConfigBasicAuth but with a custom realm description.
152+
// The realm describes the protected area and is returned in the `WWW-Authenticate` header
153+
// when the authentication fails.
154+
func ConfigBasicAuthWithRealm(realm string) *Handler[BasicUser] {
155+
return MiddlewareWithRealm(&ConfigBasicAuthenticator{}, realm)
156+
}

auth/basic_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func TestBasicAuthenticator(t *testing.T) {
3131
})
3232
assert.Equal(t, http.StatusOK, resp.StatusCode)
3333
assert.NoError(t, resp.Body.Close())
34+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
3435
})
3536

3637
t.Run("success_ptr", func(t *testing.T) {
@@ -50,6 +51,7 @@ func TestBasicAuthenticator(t *testing.T) {
5051
})
5152
assert.Equal(t, http.StatusOK, resp.StatusCode)
5253
assert.NoError(t, resp.Body.Close())
54+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
5355
})
5456

5557
t.Run("wrong_password", func(t *testing.T) {
@@ -69,6 +71,7 @@ func TestBasicAuthenticator(t *testing.T) {
6971
assert.NoError(t, resp.Body.Close())
7072
require.NoError(t, err)
7173
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
74+
assert.Equal(t, `Basic realm="Authorization required", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
7275
})
7376

7477
t.Run("service_error", func(t *testing.T) {
@@ -87,6 +90,7 @@ func TestBasicAuthenticator(t *testing.T) {
8790
})
8891
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
8992
assert.NoError(t, resp.Body.Close())
93+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
9094
})
9195

9296
t.Run("optional_success", func(t *testing.T) {
@@ -107,14 +111,15 @@ func TestBasicAuthenticator(t *testing.T) {
107111
})
108112
assert.Equal(t, http.StatusOK, resp.StatusCode)
109113
assert.NoError(t, resp.Body.Close())
114+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
110115
})
111116

112117
t.Run("optional_wrong_password", func(t *testing.T) {
113118
server, user := prepareAuthenticatorTest(t)
114119
mockUserService := &MockUserService[TestUser]{user: user}
115120
a := NewBasicAuthenticator(mockUserService, "Password")
116121
a.Optional = true
117-
authenticator := Middleware(a)
122+
authenticator := MiddlewareWithRealm(a, "custom realm")
118123

119124
request := server.NewTestRequest(http.MethodGet, "/protected", nil)
120125
request.Request().SetBasicAuth(user.Email, "wrong password")
@@ -128,6 +133,7 @@ func TestBasicAuthenticator(t *testing.T) {
128133
assert.NoError(t, resp.Body.Close())
129134
require.NoError(t, err)
130135
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
136+
assert.Equal(t, `Basic realm="custom realm", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
131137
})
132138

133139
t.Run("optional_no_auth", func(t *testing.T) {
@@ -145,6 +151,7 @@ func TestBasicAuthenticator(t *testing.T) {
145151
})
146152
assert.Equal(t, http.StatusOK, resp.StatusCode)
147153
assert.NoError(t, resp.Body.Close())
154+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
148155
})
149156

150157
t.Run("no_auth", func(t *testing.T) {
@@ -164,6 +171,7 @@ func TestBasicAuthenticator(t *testing.T) {
164171
assert.NoError(t, resp.Body.Close())
165172
require.NoError(t, err)
166173
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.no-credentials-provided")}, body)
174+
assert.Equal(t, `Basic realm="Authorization required", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
167175
})
168176

169177
t.Run("non-existing_password_field", func(t *testing.T) {
@@ -182,6 +190,7 @@ func TestBasicAuthenticator(t *testing.T) {
182190
})
183191
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
184192
assert.NoError(t, resp.Body.Close())
193+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
185194
})
186195
}
187196

@@ -200,6 +209,7 @@ func TestConfigBasicAuthenticator(t *testing.T) {
200209
})
201210
assert.Equal(t, http.StatusOK, resp.StatusCode)
202211
assert.NoError(t, resp.Body.Close())
212+
assert.Empty(t, resp.Header.Get("WWW-Authenticate"))
203213
})
204214

205215
t.Run("wrong_password", func(t *testing.T) {
@@ -219,6 +229,7 @@ func TestConfigBasicAuthenticator(t *testing.T) {
219229
assert.NoError(t, resp.Body.Close())
220230
require.NoError(t, err)
221231
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.invalid-credentials")}, body)
232+
assert.Equal(t, `Basic realm="Authorization required", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
222233
})
223234

224235
t.Run("no_auth", func(t *testing.T) {
@@ -228,7 +239,7 @@ func TestConfigBasicAuthenticator(t *testing.T) {
228239
server := testutil.NewTestServerWithOptions(t, goyave.Options{Config: cfg})
229240
request := server.NewTestRequest(http.MethodGet, "/protected", nil)
230241
request.Route = &goyave.Route{Meta: map[string]any{MetaAuth: true}}
231-
resp := server.TestMiddleware(ConfigBasicAuth(), request, func(response *goyave.Response, _ *goyave.Request) {
242+
resp := server.TestMiddleware(ConfigBasicAuthWithRealm("custom realm"), request, func(response *goyave.Response, _ *goyave.Request) {
232243
assert.Fail(t, "middleware passed despite failed authentication")
233244
response.Status(http.StatusOK)
234245
})
@@ -237,5 +248,6 @@ func TestConfigBasicAuthenticator(t *testing.T) {
237248
assert.NoError(t, resp.Body.Close())
238249
require.NoError(t, err)
239250
assert.Equal(t, map[string]string{"error": server.Lang.GetDefault().Get("auth.no-credentials-provided")}, body)
251+
assert.Equal(t, `Basic realm="custom realm", charset="UTF-8"`, resp.Header.Get("WWW-Authenticate"))
240252
})
241253
}

auth/jwt.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,7 @@ func (a *JWTAuthenticator[T]) makeError(language *lang.Language, err error) erro
299299
return fmt.Errorf("%s", language.Get("auth.jwt-invalid"))
300300
}
301301
}
302+
303+
func (a *JWTAuthenticator[T]) Scheme() string {
304+
return "Bearer"
305+
}

0 commit comments

Comments
 (0)