package server //go:generate python sri.py templates/*.html //go:generate go-bindata --nocompress --pkg server static/... templates/... import ( "bytes" "context" "encoding/gob" "encoding/json" "fmt" "html/template" "io" "io/ioutil" "log" "net/http" "net/url" "os" "strings" "time" assetfs "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/csrf" "github.com/gorilla/mux" "github.com/gorilla/sessions" "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/httputil" "git.autistici.org/id/go-sso/server/device" ) const authSessionKey = "_auth" type authSession struct { *httputil.ExpiringSession // 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{ ExpiringSession: httputil.NewExpiringSession(ttl), Username: username, UserInfo: userinfo, } } func init() { gob.Register(&authSession{}) } // 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 { authSessionStore sessions.Store authSessionLifetime time.Duration loginHandler *loginHandler loginService *LoginService keystore ksclient.Client keystoreGroups []string csrfSecret []byte renderer *renderer urlPrefix string homepageRedirectURL string allowedOrigins []string // User-configurable static data that we serve from memory. siteLogo *staticContent siteFavicon *staticContent } func sl2bl(sl []string) [][]byte { var out [][]byte for _, s := range sl { out = append(out, []byte(s)) } return out } // New returns a new Server. func New(loginService *LoginService, authClient authclient.Client, config *Config) (*Server, error) { urlPrefix := strings.TrimRight(config.URLPrefix, "/") sessionSecrets := sl2bl(config.SessionSecrets) store := sessions.NewCookieStore(sessionSecrets...) store.Options = &sessions.Options{ HttpOnly: true, Secure: true, MaxAge: 0, Path: urlPrefix + "/", } renderer := newRenderer(config) s := &Server{ authSessionLifetime: defaultAuthSessionLifetime, authSessionStore: store, loginService: loginService, urlPrefix: urlPrefix, homepageRedirectURL: config.HomepageRedirectURL, allowedOrigins: config.AllowedCORSOrigins, renderer: renderer, } if config.CSRFSecret != "" { s.csrfSecret = []byte(config.CSRFSecret) } if config.AuthSessionLifetimeSeconds > 0 { s.authSessionLifetime = time.Duration(config.AuthSessionLifetimeSeconds) * time.Second } if config.SiteLogo != "" { siteLogo, err := loadStaticContent(config.SiteLogo) if err != nil { return nil, err } s.siteLogo = siteLogo } if config.SiteFavicon != "" { siteFavicon, err := loadStaticContent(config.SiteFavicon) if err != nil { return nil, err } s.siteFavicon = siteFavicon } if config.KeyStore != nil { ks, err := ksclient.New(config.KeyStore) if err != nil { return nil, err } log.Printf("keystore client enabled") s.keystore = ks s.keystoreGroups = config.KeyStoreEnableGroups } devMgr, err := device.New(config.DeviceManager) if err != nil { return nil, err } s.loginHandler = newLoginHandler(s.loginCallback, devMgr, authClient, config.AuthService, config.U2FAppID, config.URLPrefix, renderer, sessionSecrets...) return s, nil } func inAnyGroups(groups, ref []string) bool { for _, rr := range ref { for _, gg := range groups { if gg == rr { return true } } } return false } // 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 } return true, h.keystore.Open(ctx, shard, username, password, int(h.authSessionLifetime.Seconds())) } 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. decrypted, err := h.maybeUnlockKeystore(req.Context(), username, password, userinfo) if err != nil { log.Printf("failed to unlock keystore for user %s: %v", username, err) return err } var kmsg string if decrypted { kmsg = " (key unlocked)" } log.Printf("successful login for user %s%s", username, kmsg) // Create cookie-based session for the authenticated user. session := newAuthSession(h.authSessionLifetime, username, userinfo) httpSession, _ := h.authSessionStore.Get(req, authSessionKey) // nolint httpSession.Values["data"] = session return httpSession.Save(req, w) } func (h *Server) redirectToLogin(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, h.loginHandler.makeLoginURL(req), http.StatusFound) } func (h *Server) withAuth(f func(http.ResponseWriter, *http.Request, *authSession), authFail func(http.ResponseWriter, *http.Request)) 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["data"].(*authSession) if ok && session.Valid() { f(w, req, session) return } httpSession.Options.MaxAge = -1 delete(httpSession.Values, "data") if err := httpSession.Save(req, w); err != nil { log.Printf("error saving session: %v", err) } authFail(w, req) }) } // 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) handleHomepage(w http.ResponseWriter, req *http.Request, session *authSession) { // 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. username := session.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 && 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 { http.Error(w, "Forbidden", http.StatusForbidden) return } } } // 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, groupsStr) http.Error(w, err.Error(), http.StatusBadRequest) return } session.AddService(service) if err := sessions.Save(req, w); err != nil { log.Printf("error saving session: %v", err) } // Redirect to service callback. callbackURL := serviceLoginCallback(service, destination, token) http.Redirect(w, req, callbackURL, http.StatusFound) } func (h *Server) alreadyLoggedOut(w http.ResponseWriter, req *http.Request) { http.Error(w, "You do not seem to be logged in", http.StatusBadRequest) } type logoutServiceInfo struct { URL string `json:"url"` Name string `json:"name"` } 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), }) } svcJSON, _ := json.Marshal(svcs) // nolint data := map[string]interface{}{ "Services": svcs, "ServicesJSON": string(svcJSON), "IncludeLogoutScripts": true, } // Clear the local session. Ignore errors. httpSession, _ := h.authSessionStore.Get(req, authSessionKey) // nolint delete(httpSession.Values, "data") httpSession.Options.MaxAge = -1 httpSession.Save(req, w) // nolint // Close the keystore. if h.keystore != nil { var shard string if session.UserInfo != nil { shard = session.UserInfo.Shard } if err := h.keystore.Close(req.Context(), shard, session.Username); err != nil { log.Printf("failed to wipe keystore for user %s: %v", session.Username, err) } } w.Header().Set("Content-Security-Policy", logoutContentSecurityPolicy) body, err := h.renderer.Render(req, "logout.html", data) if err != nil { log.Printf("template error in logout(): %v", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write(body) // nolint } 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 { // The root HTTP handler. This must be a gorilla/mux.Router since // sessions depend on it. // // 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 := mux.NewRouter() // If we have customized content, serve it from well-known URLs. if h.siteLogo != nil { root.Handle(h.urlFor("/img/site_logo"), h.siteLogo) } if h.siteFavicon != nil { root.Handle(h.urlFor("/favicon.ico"), h.siteFavicon) } // Serve static content to anyone. staticPath := h.urlFor("/static/") root.PathPrefix(staticPath).Handler(http.StripPrefix(staticPath, http.FileServer(&assetfs.AssetFS{ Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: "static", }))) // Build the main IDP application router, with optional CSRF // protection. m := http.NewServeMux() m.Handle(h.urlFor("/login"), h.loginHandler) m.Handle(h.urlFor("/logout"), h.withAuth(h.handleLogout, h.alreadyLoggedOut)) 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, h.redirectToLogin) userh := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == h.urlFor("/"): ssoh.ServeHTTP(w, r) case r.URL.Path == h.urlFor("/exchange"): h.handleExchange(w, r) default: idph.ServeHTTP(w, r) } }) // Add CORS headers around user-facing routes. c := cors.New(cors.Options{ AllowedOrigins: h.allowedOrigins, AllowCredentials: true, MaxAge: 86400, }) // User-facing routes require cache-busting and CSP headers. root.PathPrefix(h.urlFor("/")).Handler(withDynamicHeaders(c.Handler(userh))) return root } // 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 *;" 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") if w.Header().Get("Content-Security-Policy") == "" { w.Header().Set("Content-Security-Policy", contentSecurityPolicy) } h.ServeHTTP(w, r) }) } // 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 } type renderer struct { tpl *template.Template urlPrefix string siteName string siteLogo string siteFavicon string accountRecoveryURL string } func newRenderer(config *Config) *renderer { return &renderer{ tpl: parseEmbeddedTemplates(), urlPrefix: strings.TrimRight(config.URLPrefix, "/"), accountRecoveryURL: config.AccountRecoveryURL, siteName: config.SiteName, siteLogo: config.SiteLogo, siteFavicon: config.SiteFavicon, } } func (r *renderer) Render(req *http.Request, templateName string, data map[string]interface{}) ([]byte, error) { data["CSRFField"] = csrf.TemplateField(req) data["URLPrefix"] = r.urlPrefix data["AccountRecoveryURL"] = r.accountRecoveryURL data["SiteName"] = r.siteName data["SiteLogo"] = r.siteLogo data["SiteFavicon"] = r.siteFavicon var buf bytes.Buffer if err := r.tpl.ExecuteTemplate(&buf, templateName, data); err != nil { return nil, err } return buf.Bytes(), nil } type staticContent struct { modtime time.Time name string data []byte } func loadStaticContent(path string) (*staticContent, error) { stat, err := os.Stat(path) if err != nil { return nil, err } data, err := ioutil.ReadFile(path) // #nosec if err != nil { return nil, err } return &staticContent{ name: path, modtime: stat.ModTime(), data: data, }, nil } func (c *staticContent) ServeHTTP(w http.ResponseWriter, req *http.Request) { http.ServeContent(w, req, c.name, c.modtime, bytes.NewReader(c.data)) }