Repository navigation
Expand file tree
/
Copy pathserver.go
More file actions
348 lines (318 loc) · 12.7 KB
/
Copy pathserver.go
File metadata and controls
348 lines (318 loc) · 12.7 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
package jawsauth
//gosec:disable G117
import (
"context"
"errors"
"net/http"
"net/mail"
"net/url"
"path"
"sort"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/linkdata/jaws"
"github.com/linkdata/jaws/lib/ui"
"golang.org/x/oauth2"
)
// ErrServerNilJaws is returned by New and NewDebug when the *jaws.Jaws
// argument is nil. Server methods dereference Server.Jaws and require it
// to be non-nil for the lifetime of the Server.
var ErrServerNilJaws = errors.New("jawsauth: nil *jaws.Jaws")
func foldEmailCase(s string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
return r
}, s)
}
func normalizeEmail(s string) (email string) {
s = strings.TrimSpace(s)
if m, e := mail.ParseAddress(s); e == nil {
s = m.Address
}
email = foldEmailCase(s)
return
}
func callbackPathFromURL(u *url.URL) (callbackPath string) {
rawPath := u.Path
callbackPath = path.Clean(rawPath)
if callbackPath == "" || callbackPath == "." {
callbackPath = "/"
} else if strings.HasSuffix(rawPath, "/") && callbackPath != "/" {
callbackPath += "/"
}
return
}
// HandleFunc registers handler to serve requests for the given URI path.
//
// It matches the shape of http.ServeMux.Handle and is supplied to New and NewDebug
// so the login, logout and callback endpoints can be wired into the caller's router.
type HandleFunc func(uri string, handler http.Handler)
// EventFunc is called for login and logout lifecycle events.
//
// For [Server.LogoutEvent], hr may be nil.
type EventFunc func(sess *jaws.Session, hr *http.Request)
// FailedFunc is called when a login attempt fails.
//
// It returns true if it wrote the HTTP response itself, in which case jawsauth writes
// nothing further. Errors can contain untrusted browser-supplied text. Quote errors
// when logging them and escape them for the output format when rendering them.
type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool)
// Server provides OIDC-verified authentication for JaWS sessions.
//
// Create one with New or NewDebug. Its methods are safe for concurrent use; the
// exported configuration fields and callbacks should be set before serving requests.
type Server struct {
Jaws *jaws.Jaws
SessionKey string // default is "oidc_claims", value will be of type map[string]any
SessionTokenKey string // default is "oauth2_tokensource", value will be of type oauth2.TokenSource
SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased
SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool
HandledPaths map[string]struct{} // URI paths we have registered handlers for
LoginEvent EventFunc // if not nil, called after a successful login
LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil
LoginFailed FailedFunc // if not nil, called on failed login
Options []oauth2.AuthCodeOption // options to use, see https://pkg.go.dev/golang.org/x/oauth2#AuthCodeOption
// RequireVerifiedAdminEmail requires email_verified for a non-empty admin list.
//
// It defaults to false. When true, providers that omit email_verified cannot
// grant admin access through a non-empty list. Set before serving requests.
// Ordinary login is unaffected.
RequireVerifiedAdminEmail bool
oauth2cfg *oauth2.Config
idTokenVerifier *oidc.IDTokenVerifier
userinfoUrl string
httpClient *http.Client
ishttps bool
mu sync.Mutex // protects following fields and session email/verification pairs
admins map[string]struct{} // if not empty, emails of admins
handle403 http.Handler // handler for 403 Forbidden
authTimers map[uint64]*authTimerState
authTimerAfterFunc authTimerAfterFunc
}
// NewDebug behaves like New but can override the scheme and host of cfg.RedirectURL.
//
// overrideUrl supplies the replacement scheme and host (an empty overrideUrl disables
// the override), which is useful when serving behind a different public address during
// development.
//
// A nil jw returns ErrServerNilJaws and a nil Server. Otherwise a non-nil Server is
// always returned. OIDC is configured, and the login, logout and callback handlers
// registered via handleFn, only when cfg, handleFn and cfg.RedirectURL are all provided;
// any error from OIDC discovery is returned alongside the not-yet-Valid Server.
func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl string) (srv *Server, err error) {
if jw == nil {
err = ErrServerNilJaws
return
}
srv = &Server{
Jaws: jw,
SessionKey: "oidc_claims",
SessionTokenKey: "oauth2_tokensource",
SessionEmailKey: "email",
SessionEmailVerifiedKey: "email_verified",
HandledPaths: make(map[string]struct{}),
admins: make(map[string]struct{}),
handle403: default403handler{},
authTimers: make(map[uint64]*authTimerState),
authTimerAfterFunc: realAuthTimerAfterFunc,
} // #nosec G101
if cfg != nil && handleFn != nil && cfg.RedirectURL != "" {
if srv.oauth2cfg, srv.userinfoUrl, srv.idTokenVerifier, err = cfg.buildContext(context.Background(), overrideUrl); err == nil {
srv.httpClient = cfg.HTTPClient
var u *url.URL
if u, err = url.Parse(srv.oauth2cfg.RedirectURL); err == nil {
srv.ishttps = (u.Scheme == "https")
callbackPath := callbackPathFromURL(u)
dir := path.Dir(path.Clean(callbackPath))
srv.handlePath(callbackPath, handleFn, http.HandlerFunc(srv.HandleAuthResponse))
srv.handlePath(path.Join(dir, "login"), handleFn, http.HandlerFunc(srv.HandleLogin))
srv.handlePath(path.Join(dir, "logout"), handleFn, http.HandlerFunc(srv.HandleLogout))
jw.MakeAuth = srv.makeAuth
}
}
}
return
}
// New creates a Server providing OIDC-verified authentication for JaWS sessions.
//
// It configures the Server from cfg and registers the login, logout and OAuth2
// callback endpoints via handleFn. A nil jw returns ErrServerNilJaws. Use Valid to
// test whether OIDC authentication was successfully configured.
func New(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc) (srv *Server, err error) {
return NewDebug(jw, cfg, handleFn, "")
}
func (srv *Server) makeAuth(rq *jaws.Request) jaws.Auth {
return &JawsAuth{server: srv, sess: srv.Jaws.GetSession(rq.Initial())}
}
func (srv *Server) handlePath(p string, handleFn HandleFunc, h http.Handler) {
if _, ok := srv.HandledPaths[p]; !ok {
srv.HandledPaths[p] = struct{}{}
handleFn(p, h)
}
}
// IsAdmin returns true if email belongs to an admin, if the list of admins is empty, or if srv is nil.
//
// It parses a raw address using the rules in [Server.SetAdmins]. This lookup cannot
// verify email ownership. [Server.WrapAdmin], [Server.HandlerAdmin] and
// [JawsAuth.IsAdmin] also enforce [Server.RequireVerifiedAdminEmail].
func (srv *Server) IsAdmin(email string) (yes bool) {
yes = true
if srv != nil {
email = normalizeEmail(email)
srv.mu.Lock()
_, yes = srv.admins[email]
yes = yes || len(srv.admins) == 0
srv.mu.Unlock()
}
return
}
func (srv *Server) sessionIsAdmin(sess *jaws.Session) bool {
if srv == nil {
return true
}
srv.mu.Lock()
defer srv.mu.Unlock()
return srv.sessionIsAdminLocked(sess, srv.admins)
}
// Caller holds srv.mu.
func (srv *Server) sessionIsAdminLocked(sess *jaws.Session, admins map[string]struct{}) bool {
if len(admins) == 0 {
return true
}
if sess == nil {
return false
}
email, _ := sess.Get(srv.SessionEmailKey).(string)
verified, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool)
// Session emails are already parsed; parsing again can change quoted local parts.
_, listed := admins[foldEmailCase(email)]
return listed && (!srv.RequireVerifiedAdminEmail || verified)
}
// SetAdmins sets the emails of administrators.
//
// Addresses are parsed with [mail.ParseAddress] and ASCII letters are lowercased.
// Non-ASCII characters are matched exactly. If empty, everyone is considered an
// administrator. Session checks also apply [Server.RequireVerifiedAdminEmail].
//
// Sessions losing admin access have their JaWS requests retired with a page reload;
// they remain authenticated and can reopen non-admin pages.
func (srv *Server) SetAdmins(emails []string) {
if srv == nil {
return
}
admins := make(map[string]struct{})
for _, email := range emails {
if email = normalizeEmail(email); email != "" {
admins[email] = struct{}{}
}
}
var sessions []*jaws.Session
if srv.Jaws != nil {
sessions = srv.Jaws.Sessions()
}
var requests []*jaws.Request
srv.mu.Lock()
for _, sess := range sessions {
if sess.Get(srv.SessionKey) != nil && srv.sessionIsAdminLocked(sess, srv.admins) && !srv.sessionIsAdminLocked(sess, admins) {
requests = append(requests, sess.Requests()...)
}
}
srv.admins = admins
srv.mu.Unlock()
reloadAuthRequests(requests)
}
// GetAdmins returns a sorted list of the administrator emails. If empty, everyone is considered an administrator.
func (srv *Server) GetAdmins() (emails []string) {
if srv != nil {
srv.mu.Lock()
for k := range srv.admins {
emails = append(emails, k)
}
srv.mu.Unlock()
sort.Strings(emails)
}
return
}
// Set403Handler sets the handler used to serve 403 Forbidden responses to
// authenticated non-admin users.
//
// A nil h restores the default handler. It is safe for concurrent use.
func (srv *Server) Set403Handler(h http.Handler) {
if h == nil {
h = default403handler{}
}
srv.mu.Lock()
srv.handle403 = h
srv.mu.Unlock()
}
func (srv *Server) get403Handler() (h http.Handler) {
h = default403handler{}
if srv != nil {
srv.mu.Lock()
if srv.handle403 != nil {
h = srv.handle403
}
srv.mu.Unlock()
}
return
}
// Valid returns true if OIDC authentication is configured.
func (srv *Server) Valid() bool {
return srv != nil && srv.oauth2cfg != nil && srv.idTokenVerifier != nil
}
// wrap returns a http.Handler that requires an authenticated user before invoking h.
//
// When admin is true the user must additionally be an administrator (see SetAdmins),
// otherwise the 403 handler is served. Unauthenticated requests are redirected into the
// OIDC login flow (HandleLogin), which verifies the id_token and stores the claims in
// srv.SessionKey (with optional UserInfo fallback) before the user returns. If the
// Server is not Valid, returns h.
func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) {
rh = h
if srv.Valid() {
rh = wrapper{server: srv, handler: h, admin: admin}
}
return
}
// WrapAdmin returns a http.Handler that requires an authenticated administrator
// before invoking h.
//
// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin);
// authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are
// served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also
// applies. If the Server is not Valid, returns h.
func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) {
return srv.wrap(h, true)
}
// Wrap returns a http.Handler that requires an authenticated user before invoking h.
//
// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin), which
// verifies the id_token and stores the claims in srv.SessionKey (with optional UserInfo
// fallback) before the user returns. If the Server is not Valid, returns h.
func (srv *Server) Wrap(h http.Handler) (rh http.Handler) {
return srv.wrap(h, false)
}
// HandlerAdmin returns a http.Handler that renders the named jaws.Template with dot
// and requires an authenticated administrator.
//
// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin);
// authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler.
// [Server.RequireVerifiedAdminEmail] also applies.
// If the Server is not Valid, the template handler is returned without the
// authentication requirement.
func (srv *Server) HandlerAdmin(name string, dot any) http.Handler {
return srv.wrap(ui.Handler(srv.Jaws, name, dot), true)
}
// Handler returns a http.Handler that renders the named jaws.Template with dot
// and requires an authenticated user.
//
// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin),
// which verifies the id_token and stores the claims in srv.SessionKey (with optional
// UserInfo fallback) before the user returns. If the Server is not Valid, the template
// handler is returned without the authentication requirement.
func (srv *Server) Handler(name string, dot any) http.Handler {
return srv.wrap(ui.Handler(srv.Jaws, name, dot), false)
}