Repository navigation
Expand file tree
/
Copy pathhandler.go
More file actions
447 lines (398 loc) · 15.1 KB
/
Copy pathhandler.go
File metadata and controls
447 lines (398 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
package forwardauth
import (
"errors"
"net/url"
"sort"
"time"
)
// Handler is the main ForwardAuth handler.
type Handler struct {
config *Config
checkers []AuthChecker
stepUpMatcher *StepUpMatcher
headerBuilder *AuthHeaderBuilder
forwarded ForwardedHeaders
}
// NewHandler creates a new ForwardAuth handler.
func NewHandler(config *Config) *Handler {
config.ApplyDefaults()
h := &Handler{
config: config,
checkers: make([]AuthChecker, 0),
headerBuilder: NewAuthHeaderBuilder(config),
}
// Initialize step-up matcher if enabled
if config.StepUpEnabled {
h.stepUpMatcher = NewStepUpMatcher(true, config.StepUpPaths)
}
// Add checkers based on configuration
if config.PasswordEnabled {
h.AddChecker(NewPasswordChecker(config))
}
if config.HeaderAuthEnabled {
h.AddChecker(NewHeaderChecker(config))
}
if config.SessionEnabled {
h.AddChecker(NewSessionChecker(config))
}
return h
}
// AddChecker adds an authentication checker to the handler.
func (h *Handler) AddChecker(checker AuthChecker) {
h.checkers = append(h.checkers, checker)
// Sort by priority (lower = higher priority)
sort.Slice(h.checkers, func(i, j int) bool {
return h.checkers[i].Priority() < h.checkers[j].Priority()
})
}
// Serve runs the whole ForwardAuth endpoint for one request: the
// authentication check, the response to a failed one, and the authentication
// headers plus 200 on a successful one.
//
// It speaks only Context and Session, so it knows nothing about any web
// framework -- which is the point. Every adapter calls this rather than
// writing the sequence out again, because a second copy is a second set of
// rules about when a request is authenticated, and the two drift. See
// ServeWithStore for the usual entry point, and the fiberadapter and
// httpadapter subpackages for what an adapter has left to do.
func (h *Handler) Serve(c Context, sess Session) error {
result, err := h.Check(c, sess)
if err != nil {
return h.HandleCheckError(c, err)
}
h.SetAuthHeaders(c, result)
return c.SendStatus(200)
}
// HandleCheckError writes the response for an error Check returned.
//
// Serve is the whole endpoint and calls this itself; it is exported for a
// caller that runs Check on its own -- to inspect the AuthResult, or to
// authorize a route in process -- and then wants the endpoint's own answer for
// the failure, rather than re-running the checks to get it. Re-running them is
// not free: HeaderAuthCheckFunc and HeaderAuthGetInfoFunc are usually
// directory lookups.
func (h *Handler) HandleCheckError(c Context, err error) error {
// errors.Is, not ==: a custom AuthChecker is free to wrap these sentinels
// with %w to say which check refused, and a wrapped ErrStepUpRequired
// means step-up just as plainly as a bare one. Comparing by identity sent
// it to the login redirect instead, which is the one answer that cannot
// resolve it -- the user is already authenticated.
if errors.Is(err, ErrStepUpRequired) {
return h.HandleStepUpRequired(c)
}
return h.HandleNotAuthenticated(c)
}
// ServeWithStore is Serve with the session fetched from store first, and is
// what an adapter's endpoint handler is.
//
// A nil store means no session: the request is checked on its headers alone.
// That is the ordinary shape of a ForwardAuth deployment whose sessions live
// in the authentication service rather than in this endpoint, and of one using
// only password or header authentication.
func (h *Handler) ServeWithStore(c Context, store SessionStore) error {
var sess Session
if store != nil {
s, err := store.Get(c)
if err != nil {
return h.HandleSessionError(c, err)
}
sess = s
}
return h.Serve(c, sess)
}
// Check performs the full authentication check.
func (h *Handler) Check(c Context, sess Session) (*AuthResult, error) {
result, lastErr := h.checkAuthentication(c, sess)
if result == nil {
if lastErr != nil {
return nil, lastErr
}
return nil, ErrNotAuthenticated
}
// Check step-up requirement.
//
// The path matched must be the *original* request's path. In a ForwardAuth
// deployment the proxy calls this endpoint at a fixed URL (/_auth in the
// README's own nginx config) and passes the real target in X-Forwarded-Uri,
// so matching c.Path() compared the patterns against "/_auth" every time
// and never fired. Step-up was configured and silently inert.
if h.stepUpMatcher != nil && h.stepUpRequiredFor(c) {
if sess == nil {
return nil, ErrStepUpRequired
}
stepUpVerified := sess.Get(h.config.StepUpSessionKey)
if stepUpVerified == nil {
return nil, ErrStepUpRequired
}
if verified, ok := stepUpVerified.(bool); !ok || !verified {
return nil, ErrStepUpRequired
}
}
// Refresh auth info if needed
if result.NeedsRefresh && h.config.HeaderAuthGetInfoFunc != nil {
h.refreshAuthInfo(c, sess, result)
}
return result, nil
}
// refreshAuthInfo refreshes the authorization information from the external source.
func (h *Handler) refreshAuthInfo(c Context, sess Session, result *AuthResult) {
if sess == nil {
return
}
userPhone, _ := sess.Get(KeyUserPhone).(string)
userMail, _ := sess.Get(KeyUserMail).(string)
if userPhone == "" && userMail == "" {
return
}
// Fetch updated user info
refreshStart := time.Now()
userInfo := h.config.HeaderAuthGetInfoFunc(userPhone, userMail)
refreshDuration := time.Since(refreshStart)
if userInfo != nil {
// Replace, do not merge. Only overwriting non-empty values meant a
// user whose scopes had been revoked to none, or whose role had been
// cleared, kept the privileges already in their session forever: the
// refresh could raise privileges but never lower them.
sess.Set(KeyUserScope, userInfo.Scopes)
result.Scopes = userInfo.Scopes
sess.Set(KeyUserRole, userInfo.Role)
result.Role = userInfo.Role
// Name is explicitly optional, unlike Scopes and Role: a refresh
// callback that returns only authorization data legitimately leaves it
// empty. Replacing it unconditionally erased KeyUserName -- and with
// it the X-Auth-Name header -- after the first refresh. Only scopes and
// role need replace semantics, because that is what revocation looks
// like.
if userInfo.Name != "" {
sess.Set(KeyUserName, userInfo.Name)
result.Name = userInfo.Name
}
sess.Set(KeyAuthRefreshedAt, time.Now().Unix())
result.RefreshedAt = time.Now()
if err := sess.Save(); err != nil {
if h.config.Logger != nil {
h.config.Logger.Warn().
Err(err).
Msg("Failed to save session after auth refresh")
}
} else {
if h.config.Logger != nil {
h.config.Logger.Debug().
Str("phone", userPhone).
Str("mail", userMail).
Dur("duration", refreshDuration).
Msg("Auth info refreshed for user")
}
}
} else {
// The user no longer exists, or the directory is unreachable. Either
// way the session's cached authorisation can no longer be vouched for,
// so it is dropped rather than left in place: a deleted or suspended
// account kept its privileges for as long as the lookup kept failing.
alreadyCleared := clearedAuthorization(sess)
sess.Set(KeyUserScope, []string{})
sess.Set(KeyUserRole, "")
result.Scopes = nil
result.Role = ""
result.AuthRefreshFailed = true
// Save only when there was something to clear.
//
// KeyAuthRefreshedAt is deliberately NOT advanced on failure -- a
// refresh that did not happen must not read as one that did -- so
// every subsequent request during a directory outage arrives here
// again. Saving each time wrote the same empty scope and empty role
// to the session backend on every request, for as long as the outage
// lasted. The clearing itself still happens on every pass: it is the
// write that is redundant, not the decision.
if !alreadyCleared {
if err := sess.Save(); err != nil {
if h.config.Logger != nil {
h.config.Logger.Warn().Err(err).Msg("Failed to save session after clearing stale authorization")
}
}
}
if h.config.Logger != nil {
h.config.Logger.Warn().
Dur("duration", refreshDuration).
Msg("Failed to refresh auth info: user not found, cleared cached authorization")
}
}
}
// clearedAuthorization reports whether the session already holds the cleared
// authorization that a failed refresh writes: no role and no scopes.
//
// The scope cases are the ones SessionChecker reads back, and for the same
// reason. A backend that serializes the session -- which is most of them --
// returns the []string this package stored as a []interface{}, so treating
// only []string as cleared meant the steady state after the first failure
// never LOOKED cleared, and every later request wrote it again. The
// conversion is shared with the checker so "cleared" keeps meaning "the
// checker will read no scopes from this".
//
// A value of an unexpected type counts as NOT cleared, so the failure path
// still overwrites it. Absent counts as cleared -- there is nothing to
// replace, and an absent role reaches BuildHeaders exactly as an empty one
// does.
func clearedAuthorization(sess Session) bool {
switch role := sess.Get(KeyUserRole).(type) {
case nil:
case string:
if role != "" {
return false
}
default:
return false
}
switch scopes := sess.Get(KeyUserScope).(type) {
case nil:
return true
case []string:
return len(scopes) == 0
case []interface{}:
return len(interfaceSliceToStrings(scopes)) == 0
default:
return false
}
}
// SetAuthHeaders sets the authentication headers on the context.
func (h *Handler) SetAuthHeaders(c Context, result *AuthResult) {
h.headerBuilder.SetHeaders(c, result)
}
// HandleNotAuthenticated handles unauthenticated requests.
// For HTML requests, it redirects to the login page.
// For API requests, it returns a 401 error response.
func (h *Handler) HandleNotAuthenticated(c Context) error {
if IsHTMLRequest(c) {
callbackURL := h.forwarded.BuildCallbackURL(c, h.config.AuthHost, h.config.LoginPath, h.config.CallbackParam)
return c.Redirect(callbackURL)
}
message := "authentication required"
if h.config.TranslateFunc != nil {
message = h.config.TranslateFunc(c, "error.auth_required")
}
return h.sendErrorResponse(c, 401, message)
}
// HandleStepUpRequired handles requests that require step-up authentication.
func (h *Handler) HandleStepUpRequired(c Context) error {
if IsHTMLRequest(c) {
callbackURL := h.forwarded.BuildCallbackURL(c, h.config.AuthHost, h.config.LoginPath, h.config.CallbackParam)
// The callback URL contains "://" and its own query string, so it has
// to be escaped before being used as a query parameter value.
stepUpURL := h.config.StepUpURL + "?" + url.QueryEscape(h.config.CallbackParam) + "=" + url.QueryEscape(callbackURL)
return c.Redirect(stepUpURL)
}
message := "step-up authentication required"
if h.config.TranslateFunc != nil {
message = h.config.TranslateFunc(c, "error.step_up_required")
}
return h.sendErrorResponse(c, 403, message)
}
// HandleSessionError handles session store errors.
func (h *Handler) HandleSessionError(c Context, err error) error {
message := "session store error"
if h.config.TranslateFunc != nil {
message = h.config.TranslateFunc(c, "error.session_store_failed")
}
if h.config.Logger != nil {
h.config.Logger.Error().
Err(err).
Msg("Session store error")
}
return h.sendErrorResponse(c, 500, message)
}
// sendErrorResponse sends an error response in the appropriate format.
func (h *Handler) sendErrorResponse(c Context, statusCode int, message string) error {
if h.config.ErrorHandler != nil {
return h.config.ErrorHandler(c, statusCode, message)
}
return SendErrorResponse(c, statusCode, message)
}
// GetStepUpMatcher returns the step-up matcher.
func (h *Handler) GetStepUpMatcher() *StepUpMatcher {
return h.stepUpMatcher
}
// GetConfig returns the handler configuration.
func (h *Handler) GetConfig() *Config {
return h.config
}
// stepUpRequiredFor reports whether this request's target is a step-up route.
//
// The target comes from X-Forwarded-Uri, which only the proxy should be able
// to set. When the deployment has not declared that its proxy OVERWRITES that
// header (StepUpForwardedURITrusted), a present value may have been chosen by
// the client -- Traefik with trustForwardHeader: true forwards it verbatim --
// so "this route is not protected" is not a conclusion that can be drawn from
// it. Such a request is treated as protected: failing closed costs a step-up
// prompt, failing open costs the control entirely.
//
// A request carrying no usable forwarded path is treated as protected for the
// same reason, and this is why it does not fall back to the request's own path.
// Context.Get cannot tell an absent header from one present and empty -- Go's
// http.Header and Fiber both answer "" for each -- so neither can this, and an
// authenticated client only had to send a bare "X-Forwarded-Uri:" to turn the
// question into one about the auth endpoint. In a ForwardAuth deployment that
// endpoint is at a fixed path (/_auth in the README's own Traefik and nginx
// configurations), it matches no step-up pattern, and so the fallback could
// only ever answer "not protected" -- for the forged request and the honest
// one alike.
//
// The cost is that a proxy which does not set X-Forwarded-Uri at all now gets
// a step-up prompt on every request. That deployment had no working step-up
// control to begin with, for exactly the reason above: every path it tested
// was /_auth. A prompt is how that misconfiguration becomes visible instead of
// silently disabling the control.
func (h *Handler) stepUpRequiredFor(c Context) bool {
// Nothing is protected, so there is nothing to fail closed about. A
// matcher with no usable patterns answers false for every path by
// definition -- StepUpEnabled with an empty or all-blank StepUpPaths
// builds exactly that -- and the rule below would otherwise invert it into
// step-up on every route for a configuration asking for it on none.
if h.stepUpMatcher.PatternCount() == 0 {
return false
}
forwarded := c.Get("X-Forwarded-Uri")
raw := forwardedPath(forwarded)
if raw == "" || !h.config.StepUpForwardedURITrusted {
return true
}
// Both spellings: the bytes as forwarded, and the decoded/cleaned path a
// downstream router routes on. Either one matching means step-up, so an
// encoded "/%61dmin/settings" cannot slip past a "/admin/*" pattern that
// the router itself will honour.
if h.stepUpMatcher.RequiresStepUp(raw) {
return true
}
if canonical := canonicalForwardedPath(forwarded); canonical != raw {
return h.stepUpMatcher.RequiresStepUp(canonical)
}
return false
}
func (h *Handler) checkAuthentication(c Context, sess Session) (*AuthResult, error) {
var lastErr error
var result *AuthResult
// Try each checker in priority order
for _, checker := range h.checkers {
r, err := checker.Check(c, sess)
if err != nil {
lastErr = err
if h.config.Logger != nil {
h.config.Logger.Debug().
Str("checker", checker.Name()).
Err(err).
Msg("Auth checker failed")
}
continue
}
if r != nil && r.Authenticated {
result = r
if h.config.Logger != nil {
h.config.Logger.Debug().
Str("checker", checker.Name()).
Str("method", r.AuthMethod.String()).
Msg("Authentication successful")
}
break
}
}
return result, lastErr
}