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

3
//go:generate python sri.py templates/*.html
ale's avatar
ale committed
4 5 6 7
//go:generate go-bindata --nocompress --pkg server static/... templates/...

import (
	"encoding/gob"
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 20 21 22 23 24
	"io"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"

	assetfs "github.com/elazarl/go-bindata-assetfs"
	"github.com/gorilla/csrf"
	"github.com/gorilla/mux"
	"github.com/gorilla/sessions"

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

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

const authSessionKey = "_auth"

type authSession struct {
34
	*httputil.ExpiringSession
ale's avatar
ale committed
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

	// User name and other information (like group membership).
	Username string
	UserInfo *auth.UserInfo

	// Services the user has logged in to from this session.
	Services []string
}

// AddService adds a service to the current session (if it's not
// already there).
func (s *authSession) AddService(service string) {
	for _, svc := range s.Services {
		if svc == service {
			return
		}
	}
	s.Services = append(s.Services, service)
}

// By default, make users log in again after (almost) one day.
var defaultAuthSessionLifetime = 20 * time.Hour

func newAuthSession(ttl time.Duration, username string, userinfo *auth.UserInfo) *authSession {
	return &authSession{
60
		ExpiringSession: httputil.NewExpiringSession(ttl),
ale's avatar
ale committed
61 62 63 64 65 66 67 68 69
		Username:        username,
		UserInfo:        userinfo,
	}
}

func init() {
	gob.Register(&authSession{})
}

ale's avatar
ale committed
70 71 72 73 74 75 76 77 78 79 80 81 82
// 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
83 84 85 86 87 88 89
// Server for the SSO protocol. Provides the HTTP interface to a
// LoginService.
type Server struct {
	authSessionStore    sessions.Store
	authSessionLifetime time.Duration
	loginHandler        *loginHandler
	loginService        *LoginService
90
	keystore            ksclient.Client
ale's avatar
ale committed
91
	csrfSecret          []byte
ale's avatar
ale committed
92
	tpl                 *template.Template
93
	urlPrefix           string
ale's avatar
ale committed
94 95
}

96 97 98 99 100 101 102 103
func sl2bl(sl []string) [][]byte {
	var out [][]byte
	for _, s := range sl {
		out = append(out, []byte(s))
	}
	return out
}

ale's avatar
ale committed
104 105
// New returns a new Server.
func New(loginService *LoginService, authClient authclient.Client, config *Config) (*Server, error) {
106
	urlPrefix := strings.TrimRight(config.URLPrefix, "/")
107 108
	sessionSecrets := sl2bl(config.SessionSecrets)
	store := sessions.NewCookieStore(sessionSecrets...)
ale's avatar
ale committed
109 110 111 112
	store.Options = &sessions.Options{
		HttpOnly: true,
		Secure:   true,
		MaxAge:   0,
113
		Path:     urlPrefix + "/",
ale's avatar
ale committed
114
	}
115

ale's avatar
ale committed
116 117 118 119
	s := &Server{
		authSessionLifetime: defaultAuthSessionLifetime,
		authSessionStore:    store,
		loginService:        loginService,
120
		urlPrefix:           urlPrefix,
ale's avatar
ale committed
121
		tpl:                 parseEmbeddedTemplates(),
122 123 124
	}
	if config.CSRFSecret != "" {
		s.csrfSecret = []byte(config.CSRFSecret)
ale's avatar
ale committed
125 126 127 128 129
	}
	if config.AuthSessionLifetimeSeconds > 0 {
		s.authSessionLifetime = time.Duration(config.AuthSessionLifetimeSeconds) * time.Second
	}

130 131 132 133 134
	if config.KeyStore != nil {
		ks, err := ksclient.New(config.KeyStore)
		if err != nil {
			return nil, err
		}
135
		log.Printf("keystore client enabled")
136 137 138
		s.keystore = ks
	}

ale's avatar
ale committed
139 140 141 142
	devMgr, err := device.New(config.DeviceManager)
	if err != nil {
		return nil, err
	}
143
	s.loginHandler = newLoginHandler(s.loginCallback, devMgr, authClient, config.AuthService, config.U2FAppID, config.URLPrefix, s.tpl, sessionSecrets...)
ale's avatar
ale committed
144 145 146 147

	return s, nil
}

148 149 150 151
func (h *Server) loginCallback(w http.ResponseWriter, req *http.Request, username, password string, userinfo *auth.UserInfo) error {
	// Open the keystore for this user with the password used to
	// authenticate. Set the TTL to the duration of the
	// authenticated session.
152
	var kmsg string
153
	if h.keystore != nil {
154 155 156
		var shard string
		if userinfo != nil {
			shard = userinfo.Shard
157 158 159
			kmsg = fmt.Sprintf(" (unlocked key on shard %s)", shard)
		} else {
			kmsg = " (unlocked key)"
160 161
		}
		if err := h.keystore.Open(req.Context(), shard, username, password, int(h.authSessionLifetime.Seconds())); err != nil {
162 163 164 165 166
			log.Printf("failed to unlock keystore for user %s: %v", username, err)
			return err
		}
	}

167 168
	log.Printf("successful login for user %s%s", username, kmsg)

169
	// Create cookie-based session for the authenticated user.
ale's avatar
ale committed
170
	session := newAuthSession(h.authSessionLifetime, username, userinfo)
171
	httpSession, _ := h.authSessionStore.Get(req, authSessionKey) // nolint
ale's avatar
ale committed
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
	httpSession.Values["auth"] = session
	return httpSession.Save(req, w)
}

func (h *Server) withAuth(f func(http.ResponseWriter, *http.Request, *authSession)) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		httpSession, err := h.authSessionStore.Get(req, authSessionKey)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		session, ok := httpSession.Values["auth"].(*authSession)
		if ok && session != nil && session.Valid() {
			f(w, req, session)
			return
		}
		httpSession.Options.MaxAge = -1
189 190 191
		if err := httpSession.Save(req, w); err != nil {
			log.Printf("error saving session: %v", err)
		}
192
		http.Redirect(w, req, h.loginHandler.makeLoginURL(req), http.StatusFound)
ale's avatar
ale committed
193 194 195 196 197 198 199 200 201 202 203 204 205
	})
}

// Homepage handler. Authorizes an authenticated user to a service by
// signing a token with the user's identity. The client is redirected
// back to the service, with the signed token.
func (h *Server) handleHomepage(w http.ResponseWriter, req *http.Request, session *authSession) {
	// Extract the authorization request parameters from the HTTP
	// request.
	username := session.Username
	service := req.FormValue("s")
	destination := req.FormValue("d")
	nonce := req.FormValue("n")
206 207 208 209 210 211 212 213 214 215 216
	var groups, reqGroups []string
	if gstr := req.FormValue("g"); gstr != "" {
		reqGroups = strings.Split(gstr, ",")
		if len(reqGroups) > 0 && session.UserInfo != nil {
			groups = intersectGroups(reqGroups, session.UserInfo.Groups)
			// 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 {
217
				http.Error(w, "Forbidden", http.StatusForbidden)
218 219
				return
			}
ale's avatar
ale committed
220 221 222 223 224 225 226 227 228 229 230 231
		}
	}

	// Make the authorization request.
	token, err := h.loginService.Authorize(username, service, destination, nonce, groups)
	if err != nil {
		log.Printf("auth error: %v: user=%s service=%s destination=%s nonce=%s groups=%s", err, username, service, destination, nonce, req.FormValue("g"))
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	session.AddService(service)
232 233 234
	if err := sessions.Save(req, w); err != nil {
		log.Printf("error saving session: %v", err)
	}
ale's avatar
ale committed
235 236

	// Redirect to service callback.
ale's avatar
ale committed
237
	callbackURL := serviceLoginCallback(service, destination, token)
ale's avatar
ale committed
238 239 240
	http.Redirect(w, req, callbackURL, http.StatusFound)
}

ale's avatar
ale committed
241
type logoutServiceInfo struct {
242 243
	URL  string `json:"url"`
	Name string `json:"name"`
ale's avatar
ale committed
244 245 246 247 248 249 250 251 252 253 254 255 256
}

func (h *Server) handleLogout(w http.ResponseWriter, req *http.Request, session *authSession) {
	var svcs []logoutServiceInfo
	for _, svc := range session.Services {
		svcs = append(svcs, logoutServiceInfo{
			Name: svc,
			URL:  serviceLogoutCallback(svc),
		})
	}

	data := map[string]interface{}{
		"CSRFField": csrf.TemplateField(req),
257
		"URLPrefix": h.urlPrefix,
ale's avatar
ale committed
258 259 260 261 262
		"Services":  svcs,
		"IsPOST":    false,
	}
	if req.Method == "POST" {
		data["IsPOST"] = true
ale's avatar
ale committed
263
		data["IncludeLogoutScripts"] = true
264
		svcJSON, _ := json.Marshal(svcs) // nolint
ale's avatar
ale committed
265
		data["ServicesJSON"] = string(svcJSON)
ale's avatar
ale committed
266

267 268
		// Clear the local session. Ignore errors.
		httpSession, _ := h.authSessionStore.Get(req, authSessionKey) // nolint
ale's avatar
ale committed
269
		httpSession.Options.MaxAge = -1
270
		httpSession.Save(req, w) // nolint
271 272 273

		// Close the keystore.
		if h.keystore != nil {
274 275 276 277 278
			var shard string
			if session.UserInfo != nil {
				shard = session.UserInfo.Shard
			}
			if err := h.keystore.Close(req.Context(), shard, session.Username); err != nil {
279 280 281
				log.Printf("failed to wipe keystore for user %s: %v", session.Username, err)
			}
		}
282 283

		w.Header().Set("Content-Security-Policy", logoutContentSecurityPolicy)
ale's avatar
ale committed
284 285
	}

286
	h.tpl.ExecuteTemplate(w, "logout.html", data) // nolint
ale's avatar
ale committed
287 288 289 290 291 292 293 294 295
}

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")

296
	token, err := h.loginService.Exchange(curToken, curService, curNonce, newService, newNonce)
297 298 299 300 301 302 303
	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
304 305 306 307 308
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

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

312 313 314 315
func (h *Server) urlFor(path string) string {
	return h.urlPrefix + path
}

ale's avatar
ale committed
316 317
// Handler returns the http.Handler for the SSO server application.
func (h *Server) Handler() http.Handler {
318 319
	// The root HTTP handler. This must be a gorilla/mux.Router since
	// sessions depend on it.
320 321 322 323 324
	//
	// 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.
325
	root := mux.NewRouter()
ale's avatar
ale committed
326

327
	// Serve static content to anyone.
328 329
	staticPath := h.urlFor("/static/")
	root.PathPrefix(staticPath).Handler(http.StripPrefix(staticPath, http.FileServer(&assetfs.AssetFS{
ale's avatar
ale committed
330 331 332 333 334
		Asset:     Asset,
		AssetDir:  AssetDir,
		AssetInfo: AssetInfo,
		Prefix:    "static",
	})))
335

336 337 338
	// Build the main IDP application router, with optional CSRF
	// protection.
	m := http.NewServeMux()
339 340
	m.Handle(h.urlFor("/login"), h.loginHandler)
	m.Handle(h.urlFor("/logout"), h.withAuth(h.handleLogout))
341 342 343 344 345 346 347 348 349 350 351
	idph := http.Handler(m)
	if h.csrfSecret != nil {
		idph = csrf.Protect(h.csrfSecret)(idph)
	}

	// Add the SSO provider endpoints (root path and /exchange),
	// which do not need CSRF. We use a HandlerFunc to bypass the
	// '/' dispatch semantics of the standard http.ServeMux.
	ssoh := h.withAuth(h.handleHomepage)
	userh := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
352
		case r.Method == "GET" && r.URL.Path == h.urlFor("/"):
353
			ssoh.ServeHTTP(w, r)
354
		case r.URL.Path == h.urlFor("/exchange"):
355 356 357 358 359 360 361
			h.handleExchange(w, r)
		default:
			idph.ServeHTTP(w, r)
		}
	})

	// User-facing routes require cache-busting and CSP headers.
362
	root.PathPrefix(h.urlFor("/")).Handler(withDynamicHeaders(userh))
ale's avatar
ale committed
363

364
	return root
ale's avatar
ale committed
365 366 367 368 369
}

// A relatively strict CSP.
const contentSecurityPolicy = "default-src 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self';"

370 371
// Slightly looser CSP for the logout page: it needs to load remote
// images.
372
const logoutContentSecurityPolicy = "default-src 'none'; img-src *; script-src 'self'; style-src 'self'; connect-src *;"
373

ale's avatar
ale committed
374 375 376 377 378 379 380 381
func withDynamicHeaders(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Pragma", "no-cache")
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Expires", "-1")
		w.Header().Set("X-Frame-Options", "NONE")
		w.Header().Set("X-XSS-Protection", "1; mode=block")
		w.Header().Set("X-Content-Type-Options", "nosniff")
382 383 384
		if w.Header().Get("Content-Security-Policy") == "" {
			w.Header().Set("Content-Security-Policy", contentSecurityPolicy)
		}
ale's avatar
ale committed
385 386 387
		h.ServeHTTP(w, r)
	})
}
388

ale's avatar
ale committed
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408
// Parse the templates that are embedded with the binary (in bindata.go).
func parseEmbeddedTemplates() *template.Template {
	root := template.New("").Funcs(template.FuncMap{
		"json": toJSON,
	})
	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
}