http.go 13.8 KB
Newer Older
ale's avatar
ale committed
1
2
package server

3
//go:generate go run scripts/sri.go --package server --output sri_map.go static
ale's avatar
ale committed
4
5
6
//go:generate go-bindata --nocompress --pkg server static/... templates/...

import (
ale's avatar
ale committed
7
	"context"
8
	"encoding/json"
ale's avatar
ale committed
9
	"fmt"
ale's avatar
ale committed
10
	"html/template"
ale's avatar
ale committed
11
12
13
14
15
16
17
18
19
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	assetfs "github.com/elazarl/go-bindata-assetfs"
	"github.com/gorilla/csrf"
ale's avatar
ale committed
20
	"github.com/rs/cors"
ale's avatar
ale committed
21
22
23

	"git.autistici.org/id/auth"
	authclient "git.autistici.org/id/auth/client"
24
25
	ksclient "git.autistici.org/id/keystore/client"

ale's avatar
ale committed
26
	"git.autistici.org/id/go-sso/server/device"
27
	"git.autistici.org/id/go-sso/server/httputil"
ale's avatar
ale committed
28
	"git.autistici.org/id/go-sso/server/login"
ale's avatar
ale committed
29
30
)

ale's avatar
ale committed
31
32
// A relatively strict CSP.
const contentSecurityPolicy = "default-src 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self';"
ale's avatar
ale committed
33

ale's avatar
ale committed
34
35
36
// Slightly looser CSP for the logout page: it needs to load remote
// images.
const logoutContentSecurityPolicy = "default-src 'none'; img-src *; script-src 'self'; style-src 'self'; connect-src *;"
ale's avatar
ale committed
37

ale's avatar
ale committed
38
39
40
41
42
43
44
45
46
47
48
49
50
// Returns the URL of the login handler on the target service.
func serviceLoginCallback(service, destination, token string) string {
	v := make(url.Values)
	v.Set("t", token)
	v.Set("d", destination)
	return fmt.Sprintf("https://%ssso_login?%s", service, v.Encode())
}

// Returns the URL of the logout handler on the target service.
func serviceLogoutCallback(service string) string {
	return fmt.Sprintf("https://%ssso_logout", service)
}

ale's avatar
ale committed
51
52
53
// Server for the SSO protocol. Provides the HTTP interface to a
// LoginService.
type Server struct {
ale's avatar
ale committed
54
	authSessionLifetime int
ale's avatar
ale committed
55
	loginService        *LoginService
ale's avatar
ale committed
56
	keystore            ksclient.Client
ale's avatar
ale committed
57
	keystoreGroups      []string
ale's avatar
ale committed
58
	renderer            *httputil.Renderer
59
	urlPrefix           string
60
	homepageRedirectURL string
ale's avatar
ale committed
61
	handler             http.Handler
62
63
}

ale's avatar
ale committed
64
65
// New returns a new Server.
func New(loginService *LoginService, authClient authclient.Client, config *Config) (*Server, error) {
66
	urlPrefix := strings.TrimRight(config.URLPrefix, "/")
ale's avatar
ale committed
67
68
69
70
71
72
73
74
75
76
77
78
	renderer := httputil.NewRenderer(
		parseEmbeddedTemplates(),
		map[string]interface{}{
			"URLPrefix":          urlPrefix,
			"AccountRecoveryURL": config.AccountRecoveryURL,
			"SiteName":           config.SiteName,
			"SiteLogo":           config.SiteLogo,
			"SiteFavicon":        config.SiteFavicon,
		},
	)

	h := &Server{
ale's avatar
ale committed
79
		loginService:        loginService,
80
		urlPrefix:           urlPrefix,
81
		homepageRedirectURL: config.HomepageRedirectURL,
ale's avatar
ale committed
82
		authSessionLifetime: config.AuthSessionLifetimeSeconds,
83
		renderer:            renderer,
84
	}
ale's avatar
ale committed
85
86
87
88
89
90
91
92
93

	if config.KeyStore != nil {
		ks, err := ksclient.New(config.KeyStore)
		if err != nil {
			return nil, err
		}
		log.Printf("keystore client enabled")
		h.keystore = ks
		h.keystoreGroups = config.KeyStoreEnableGroups
ale's avatar
ale committed
94
	}
ale's avatar
ale committed
95
96
97
98

	devMgr, err := device.New(config.DeviceManager, urlPrefix)
	if err != nil {
		return nil, err
ale's avatar
ale committed
99
100
	}

101
102
103
104
105
	// The root HTTP handler. If a URL prefix is set, we can't
	// just add a StripPrefix in front of everything, as the
	// handlers need access to the actual full request URL, so we
	// just inject the prefix everywhere.
	root := http.NewServeMux()
ale's avatar
ale committed
106
107

	// If we have customized content, serve it from well-known URLs.
108
	if config.SiteLogo != "" {
ale's avatar
ale committed
109
		siteLogo, err := httputil.LoadStaticContent(config.SiteLogo)
110
111
112
		if err != nil {
			return nil, err
		}
ale's avatar
ale committed
113
		root.Handle(h.urlFor("/img/site_logo"), siteLogo)
114
115
	}
	if config.SiteFavicon != "" {
ale's avatar
ale committed
116
		siteFavicon, err := httputil.LoadStaticContent(config.SiteFavicon)
117
118
119
		if err != nil {
			return nil, err
		}
ale's avatar
ale committed
120
		root.Handle(h.urlFor("/favicon.ico"), siteFavicon)
ale's avatar
ale committed
121
122
123
124
125
126
127
	} else if urlPrefix == "" {
		// Block default favicon requests (created by error pages, or
		// if we don't set a custom favicon) *before* the login
		// handler runs, or it will invalidate the session!
		root.HandleFunc(h.urlFor("/favicon.ico"), func(w http.ResponseWriter, r *http.Request) {
			http.NotFound(w, r)
		})
128
129
	}

ale's avatar
ale committed
130
131
	// Serve static content to anyone.
	staticPath := h.urlFor("/static/")
132
	root.Handle(staticPath, http.StripPrefix(staticPath, http.FileServer(&assetfs.AssetFS{
ale's avatar
ale committed
133
134
135
136
137
		Asset:     Asset,
		AssetDir:  AssetDir,
		AssetInfo: AssetInfo,
		Prefix:    "static",
	})))
138

ale's avatar
ale committed
139
140
141
	// Add the /exchange endpoint (which does not use the normal
	// HTTP-based login workflow).
	root.HandleFunc(h.urlFor("/exchange"), h.handleExchange)
ale's avatar
ale committed
142

143
144
145
	// Build the main application router (which only serves / and
	// /logout), wrap it with a login handler, optional CSRF
	// protection, custom HTTP headers, etc.
ale's avatar
ale committed
146
	mainh := http.NewServeMux()
ale's avatar
ale committed
147
148
	mainh.HandleFunc(h.urlFor("/logout"), h.handleLogout)
	mainh.HandleFunc(h.urlFor("/"), h.handleGrantTicket)
ale's avatar
ale committed
149

ale's avatar
ale committed
150
151
152
153
154
155
156
157
158
	loginh := login.New(mainh, devMgr, authClient,
		config.AuthService, config.U2FAppID, urlPrefix,
		config.HomepageRedirectURL, renderer, h.loginCallback,
		sl2bl(config.SessionSecrets),
		time.Duration(config.AuthSessionLifetimeSeconds)*time.Second)

	apph := httputil.WithDynamicHeaders(loginh, contentSecurityPolicy)
	if config.CSRFSecret != "" {
		apph = csrf.Protect([]byte(config.CSRFSecret))(apph)
ale's avatar
ale committed
159
	}
ale's avatar
ale committed
160
161
162
163
164
165
166
167
168
169

	// Add CORS headers on the main IDP endpoints.
	corsp := cors.New(cors.Options{
		AllowedOrigins:   config.AllowedCORSOrigins,
		AllowedHeaders:   []string{"*"},
		AllowCredentials: true,
		MaxAge:           86400,
	})
	apph = corsp.Handler(apph)

170
171
172
173
174
175
176
177
178
179
180
181
182
183
	// Now we need to remap 'apph' onto 'root'. We do this by
	// whitelisting certain methods only, which allows us to
	// return 404s *before* authentication.
	root.Handle(h.urlFor("/login"), apph)
	root.Handle(h.urlFor("/login/"), apph)
	root.Handle(h.urlFor("/logout"), apph)
	root.HandleFunc(h.urlFor("/"), func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != h.urlFor("/") {
			http.NotFound(w, r)
			return
		}
		apph.ServeHTTP(w, r)
	})

ale's avatar
ale committed
184
185
186
	h.handler = root

	return h, nil
ale's avatar
ale committed
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
}

// We unlock the keystore if the following conditions are met:
// keystore_enable_groups is set, userinfo is not nil, and the groups match.
func (h *Server) maybeUnlockKeystore(ctx context.Context, username, password string, userinfo *auth.UserInfo) (bool, error) {
	if h.keystore == nil {
		return false, nil
	}

	var shard string
	if len(h.keystoreGroups) > 0 {
		if userinfo == nil {
			return false, nil
		}
		if !inAnyGroups(userinfo.Groups, h.keystoreGroups) {
			return false, nil
		}
		shard = userinfo.Shard
	}
ale's avatar
ale committed
206
207
208
	// Add a 'grace time' of 30 minutes to the key ttl.
	ttl := h.authSessionLifetime + 1800
	return true, h.keystore.Open(ctx, shard, username, password, ttl)
ale's avatar
ale committed
209
210
}

ale's avatar
ale committed
211
212
213
214
215
216
// Callback called by the login handler whenever a user successfully
// logs in. We use it to unlock the keystore with the user's password.
func (h *Server) loginCallback(ctx context.Context, username, password string, userinfo *auth.UserInfo) error {
	// Open the keystore for this user, with the same password
	// used to authenticate.
	decrypted, err := h.maybeUnlockKeystore(ctx, username, password, userinfo)
ale's avatar
ale committed
217
	if err != nil {
ale's avatar
ale committed
218
		return fmt.Errorf("failed to unlock keystore for user %s: %v", username, err)
219
220
	}

ale's avatar
ale committed
221
222
223
224
	var kmsg string
	if decrypted {
		kmsg = " (key unlocked)"
	}
ale's avatar
ale committed
225
	log.Printf("successful login for user %s%s", username, kmsg)
ale's avatar
ale committed
226
	return nil
ale's avatar
ale committed
227
228
}

229
230
231
// Token signing handler. Authorizes an authenticated user to a service by
// signing a token with the user's identity. The client is redirected back to
// the original service, with the signed token.
ale's avatar
ale committed
232
func (h *Server) handleGrantTicket(w http.ResponseWriter, req *http.Request) {
ale's avatar
ale committed
233
234
235
236
237
238
239
	// We need this check here because this handler is usually
	// mounted at the application root.
	if req.URL.Path != h.urlFor("/") {
		http.NotFound(w, req)
		return
	}

ale's avatar
ale committed
240
	// Extract the authorization request parameters from the HTTP
241
242
243
244
245
246
	// request query args.
	//
	// *NOTE*: we do not want to parse the request body, in case
	// it is a POST request redirected from a 307, so we do not
	// call req.FormValue() but look directly into request.URL
	// instead.
ale's avatar
ale committed
247
248
249
250
251
252
253
	auth, ok := login.GetAuth(req.Context())
	if !ok {
		http.Error(w, "No valid session", http.StatusBadRequest)
		return
	}

	username := auth.Username
254
255
256
257
	service := req.URL.Query().Get("s")
	destination := req.URL.Query().Get("d")
	nonce := req.URL.Query().Get("n")
	groupsStr := req.URL.Query().Get("g")
ale's avatar
ale committed
258

259
260
261
262
263
264
265
266
267
268
269
270
271
	// If the above parameters are unset, we're probably faced with a user
	// that reached this URL by other means. Redirect them to the
	// configured homepageRedirectURL, or at least return a slightly more
	// user-friendly error.
	if service == "" || destination == "" {
		if h.homepageRedirectURL != "" {
			http.Redirect(w, req, h.homepageRedirectURL, http.StatusFound)
		} else {
			http.Error(w, "You are not supposed to reach this page directly. Use the back button in your browser instead.", http.StatusBadRequest)
		}
		return
	}

272
273
274
275
276
	// Compute the intersection of the user's groups and the
	// requested groups, to obtain the group memberships to grant.
	var groups []string
	if groupsStr != "" {
		reqGroups := strings.Split(groupsStr, ",")
277
278
279
280
281
		if len(reqGroups) > 0 {
			if auth.UserInfo != nil {
				groups = intersectGroups(reqGroups, auth.UserInfo.Groups)
				log.Printf("intersectGroups(%+v, %+v) -> %+v", reqGroups, auth.UserInfo.Groups, groups)
			}
282
283
284
285
286
287
288
289
290
291
292
293
			// We only make this check here as a convenience to
			// the user (we may be able to show a nicer UI): the
			// actual group ACL must be applied on the destination
			// service, because the 'g' parameter is untrusted at
			// this stage.
			if len(groups) == 0 {
				http.Error(w, "Forbidden", http.StatusForbidden)
				return
			}
		}
	}

294
295
296
297
	// Make the authorization request. Tickets should not live
	// longer than the authentication session.
	ttl := auth.Deadline.Sub(time.Now().UTC())
	token, err := h.loginService.Authorize(username, service, destination, nonce, groups, ttl)
ale's avatar
ale committed
298
	if err != nil {
299
		log.Printf("auth error: %v: user=%s service=%s destination=%s nonce=%s groups=%s", err, username, service, destination, nonce, groupsStr)
ale's avatar
ale committed
300
301
302
303
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

ale's avatar
ale committed
304
305
	log.Printf("authorized %s for %s (ttl=%ds)", username, service, int(ttl.Seconds()))

ale's avatar
ale committed
306
307
	// Record the service in the session.
	auth.AddService(service)
ale's avatar
ale committed
308
309

	// Redirect to service callback.
ale's avatar
ale committed
310
	callbackURL := serviceLoginCallback(service, destination, token)
ale's avatar
ale committed
311
312
313
	http.Redirect(w, req, callbackURL, http.StatusFound)
}

ale's avatar
ale committed
314
type logoutServiceInfo struct {
315
316
	URL  string `json:"url"`
	Name string `json:"name"`
ale's avatar
ale committed
317
318
}

ale's avatar
ale committed
319
320
321
322
323
324
325
326
327
328
329
// Logout handler. We generate a page that triggers child logout
// requests to all the services the user is logged in to.
func (h *Server) handleLogout(w http.ResponseWriter, req *http.Request) {
	auth, ok := login.GetAuth(req.Context())
	if !ok {
		http.Error(w, "No valid session", http.StatusBadRequest)
		return
	}

	//

ale's avatar
ale committed
330
	var svcs []logoutServiceInfo
ale's avatar
ale committed
331
	for _, svc := range auth.Services {
ale's avatar
ale committed
332
333
334
335
336
337
		svcs = append(svcs, logoutServiceInfo{
			Name: svc,
			URL:  serviceLogoutCallback(svc),
		})
	}

338
	svcJSON, _ := json.Marshal(svcs) // nolint
ale's avatar
ale committed
339
	data := map[string]interface{}{
340
341
		"Services":             svcs,
		"ServicesJSON":         string(svcJSON),
342
		"IncludeLogoutScripts": true,
ale's avatar
ale committed
343
	}
344

345
346
347
	// Close the keystore.
	if h.keystore != nil {
		var shard string
ale's avatar
ale committed
348
349
		if auth.UserInfo != nil {
			shard = auth.UserInfo.Shard
350
		}
ale's avatar
ale committed
351
352
353
		if err := h.keystore.Close(req.Context(), shard, auth.Username); err != nil {
			// This is not a fatal error.
			log.Printf("warning: failed to wipe keystore for user %s: %v", auth.Username, err)
354
		}
ale's avatar
ale committed
355
356
	}

357
	w.Header().Set("Content-Security-Policy", logoutContentSecurityPolicy)
ale's avatar
ale committed
358
	h.renderer.Render(w, req, "logout.html", data)
ale's avatar
ale committed
359
360
361
362
363
364
365
366
367
}

func (h *Server) handleExchange(w http.ResponseWriter, req *http.Request) {
	curToken := req.FormValue("cur_tkt")
	curService := req.FormValue("cur_svc")
	curNonce := req.FormValue("cur_nonce")
	newService := req.FormValue("new_svc")
	newNonce := req.FormValue("new_nonce")

368
	token, err := h.loginService.Exchange(curToken, curService, curNonce, newService, newNonce)
ale's avatar
ale committed
369
370
371
372
373
374
375
	switch {
	case err == ErrUnauthorized:
		log.Printf("unauthorized exchange request (%s -> %s)", curService, newService)
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	case err != nil:
		log.Printf("exchange error (%s -> %s): %v", curService, newService, err)
ale's avatar
ale committed
376
377
378
379
380
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	w.Header().Set("Content-Type", "text/plain")
381
	io.WriteString(w, token) // nolint
ale's avatar
ale committed
382
383
}

384
385
386
387
func (h *Server) urlFor(path string) string {
	return h.urlPrefix + path
}

ale's avatar
ale committed
388
389
// Handler returns the http.Handler for the SSO server application.
func (h *Server) Handler() http.Handler {
ale's avatar
ale committed
390
	return h.handler
ale's avatar
ale committed
391
}
ale's avatar
ale committed
392

ale's avatar
ale committed
393
394
395
396
// Parse the templates that are embedded with the binary (in bindata.go).
func parseEmbeddedTemplates() *template.Template {
	root := template.New("").Funcs(template.FuncMap{
		"json": toJSON,
397
		"SRI":  sriIntegrity,
ale's avatar
ale committed
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
	})
	files, err := AssetDir("templates")
	if err != nil {
		log.Fatalf("no asset dir for templates: %v", err)
	}
	for _, f := range files {
		b, err := Asset("templates/" + f)
		if err != nil {
			log.Fatalf("could not read embedded template %s: %v", f, err)
		}
		if _, err := root.New(f).Parse(string(b)); err != nil {
			log.Fatalf("error parsing template %s: %v", f, err)
		}
	}
	return root
}
414

ale's avatar
ale committed
415
416
417
418
func sl2bl(sl []string) [][]byte {
	var out [][]byte
	for _, s := range sl {
		out = append(out, []byte(s))
419
	}
ale's avatar
ale committed
420
	return out
421
422
}

ale's avatar
ale committed
423
424
425
426
427
428
429
430
431
// Returns true if the intersection of the sets isn't empty (in O(N^2)
// time).
func inAnyGroups(groups, ref []string) bool {
	for _, rr := range ref {
		for _, gg := range groups {
			if gg == rr {
				return true
			}
		}
432
	}
ale's avatar
ale committed
433
	return false
434
435
}

ale's avatar
ale committed
436
437
438
439
440
441
442
443
444
445
446
447
// Returns the intersection of two string lists (in O(N^2) time).
func intersectGroups(a, b []string) []string {
	var out []string
	for _, aa := range a {
		for _, bb := range b {
			if aa == bb {
				out = append(out, aa)
				break
			}
		}
	}
	return out
448
}
449

ale's avatar
ale committed
450
451
452
453
454
455
456
457
458
// Template helper function that encodes its input as JSON.
func toJSON(obj interface{}) string {
	data, err := json.Marshal(obj)
	if err != nil {
		return ""
	}
	return string(data)
}

459
460
461
462
463
464
465
466
467
// Return an integrity= attribute for the given URI (which should be
// supplied without an eventual prefix).
func sriIntegrity(uri string) template.HTML {
	sri, ok := sriMap[uri]
	if !ok {
		return template.HTML("")
	}
	return template.HTML(fmt.Sprintf(" integrity=\"%s\"", sri))
}