package server //go:generate go run scripts/sri.go --package server --output sri_map.go static //go:generate go-bindata --nocompress --pkg server static/... templates/... import ( "context" "encoding/json" "fmt" "html/template" "io" "log" "net/http" "net/url" "strings" "time" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/csrf" "github.com/rs/cors" "git.autistici.org/id/auth" authclient "git.autistici.org/id/auth/client" ksclient "git.autistici.org/id/keystore/client" "git.autistici.org/id/go-sso/server/device" "git.autistici.org/id/go-sso/server/httputil" "git.autistici.org/id/go-sso/server/login" ) // A relatively strict CSP. const contentSecurityPolicy = "default-src 'none'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self';" // 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 *;" // 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) } // Server for the SSO protocol. Provides the HTTP interface to a // LoginService. type Server struct { authSessionLifetime int loginService *LoginService keystore ksclient.Client keystoreGroups []string renderer *httputil.Renderer urlPrefix string homepageRedirectURL string handler http.Handler } // New returns a new Server. func New(loginService *LoginService, authClient authclient.Client, config *Config) (*Server, error) { urlPrefix := strings.TrimRight(config.URLPrefix, "/") renderer := httputil.NewRenderer( parseEmbeddedTemplates(), map[string]interface{}{ "URLPrefix": urlPrefix, "AccountRecoveryURL": config.AccountRecoveryURL, "SiteName": config.SiteName, "SiteLogo": config.SiteLogo, "SiteFavicon": config.SiteFavicon, }, ) h := &Server{ loginService: loginService, urlPrefix: urlPrefix, homepageRedirectURL: config.HomepageRedirectURL, authSessionLifetime: config.AuthSessionLifetimeSeconds, renderer: renderer, } 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 } devMgr, err := device.New(config.DeviceManager, urlPrefix) if err != nil { return nil, err } // 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() // If we have customized content, serve it from well-known URLs. if config.SiteLogo != "" { siteLogo, err := httputil.LoadStaticContent(config.SiteLogo) if err != nil { return nil, err } root.Handle(h.urlFor("/img/site_logo"), siteLogo) } if config.SiteFavicon != "" { siteFavicon, err := httputil.LoadStaticContent(config.SiteFavicon) if err != nil { return nil, err } root.Handle(h.urlFor("/favicon.ico"), siteFavicon) } 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) }) } // Serve static content to anyone. staticPath := h.urlFor("/static/") root.Handle(staticPath, http.StripPrefix(staticPath, http.FileServer(&assetfs.AssetFS{ Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: "static", }))) // Add the /exchange endpoint (which does not use the normal // HTTP-based login workflow). root.HandleFunc(h.urlFor("/exchange"), h.handleExchange) // Build the main application router (which only serves / and // /logout), wrap it with a login handler, optional CSRF // protection, custom HTTP headers, etc. mainh := http.NewServeMux() mainh.HandleFunc(h.urlFor("/logout"), h.handleLogout) mainh.HandleFunc(h.urlFor("/"), h.handleGrantTicket) 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) } // 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) // 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) }) h.handler = root return h, nil } // 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 } // 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) } // 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) if err != nil { return fmt.Errorf("failed to unlock keystore for user %s: %v", username, err) } var kmsg string if decrypted { kmsg = " (key unlocked)" } log.Printf("successful login for user %s%s", username, kmsg) return nil } // 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. func (h *Server) handleGrantTicket(w http.ResponseWriter, req *http.Request) { // 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 } // Extract the authorization request parameters from the HTTP // 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. auth, ok := login.GetAuth(req.Context()) if !ok { http.Error(w, "No valid session", http.StatusBadRequest) return } username := auth.Username service := req.URL.Query().Get("s") destination := req.URL.Query().Get("d") nonce := req.URL.Query().Get("n") groupsStr := req.URL.Query().Get("g") // 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 } // 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, ",") 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) } // 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 } } } // 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) if err != nil { log.Printf("auth error: %v: user=%s service=%s destination=%s nonce=%s groups=%s", err, username, service, destination, nonce, groupsStr) http.Error(w, err.Error(), http.StatusBadRequest) return } log.Printf("authorized %s for %s (ttl=%ds)", username, service, int(ttl.Seconds())) // Record the service in the session. auth.AddService(service) // Redirect to service callback. callbackURL := serviceLoginCallback(service, destination, token) http.Redirect(w, req, callbackURL, http.StatusFound) } type logoutServiceInfo struct { URL string `json:"url"` Name string `json:"name"` } // 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 } // var svcs []logoutServiceInfo for _, svc := range auth.Services { svcs = append(svcs, logoutServiceInfo{ Name: svc, URL: serviceLogoutCallback(svc), }) } svcJSON, _ := json.Marshal(svcs) // nolint data := map[string]interface{}{ "Services": svcs, "ServicesJSON": string(svcJSON), "IncludeLogoutScripts": true, } // Close the keystore. if h.keystore != nil { var shard string if auth.UserInfo != nil { shard = auth.UserInfo.Shard } 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) } } w.Header().Set("Content-Security-Policy", logoutContentSecurityPolicy) h.renderer.Render(w, req, "logout.html", data) } 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") token, err := h.loginService.Exchange(curToken, curService, curNonce, newService, newNonce) 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) http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/plain") io.WriteString(w, token) // nolint } func (h *Server) urlFor(path string) string { return h.urlPrefix + path } // Handler returns the http.Handler for the SSO server application. func (h *Server) Handler() http.Handler { return h.handler } // Parse the templates that are embedded with the binary (in bindata.go). func parseEmbeddedTemplates() *template.Template { root := template.New("").Funcs(template.FuncMap{ "json": toJSON, "SRI": sriIntegrity, }) 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 } func sl2bl(sl []string) [][]byte { var out [][]byte for _, s := range sl { out = append(out, []byte(s)) } return out } // 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 } } } return false } // 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 } // 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) } // 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)) }