package server import ( "errors" "net/http" "sort" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" ) // The web interface covers what a browser is better at than the command // line: creating a vault and setting its password, and the administration of // people — invites, accounts, groups and who reaches which vault. It is not // a password manager and never shows a vault's contents; the server does not // open a vault except to merge one. const webCookie = "keeporg_web" // webSession is a signed-in browser. type webSession struct { user *store.User csrf string token string } // webUser resolves the session cookie, or nil when the browser is not // signed in. func (s *Server) webUser(r *http.Request) *webSession { c, err := r.Cookie(webCookie) if err != nil || c.Value == "" { return nil } var ( out *webSession seen time.Time ) s.st.Read(func(st *store.State) { sess := st.Sessions[c.Value] if sess == nil || time.Now().After(sess.ExpiresAt) { return } if u := st.Users[sess.UserID]; u != nil && !u.Disabled { cp := *u out = &webSession{user: &cp, csrf: sess.CSRF, token: c.Value} seen = sess.LastSeenAt } }) if out != nil { s.markSeen(c.Value, seen) } return out } // webHandler is a handler that needs a signed-in browser. type webHandler func(http.ResponseWriter, *http.Request, *webSession) // webPage serves a page to a signed-in browser, and the sign-in form to // anyone else. func (s *Server) webPage(fn webHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess := s.webUser(r) if sess == nil { s.renderSignIn(w, r, "") return } fn(w, r, sess) } } // webForm accepts a form from a signed-in browser. Every form on every page // goes through here, so the CSRF check cannot be forgotten on one of them. func (s *Server) webForm(fn webHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess := s.webUser(r) if sess == nil { s.renderSignIn(w, r, "") return } if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) { fail(w, http.StatusForbidden, "csrf", "stale form, reload the page") return } fn(w, r, sess) } } // checkCSRF verifies the form token against the session's. func (s *Server) checkCSRF(r *http.Request, sess *webSession) bool { return sess.csrf != "" && secret.Equal(sess.csrf, r.FormValue("csrf")) } func (s *Server) setWebCookie(w http.ResponseWriter, token string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: webCookie, Value: token, Path: "/", HttpOnly: true, Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"), SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds()), }) } func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) { if s.webUser(r) != nil { http.Redirect(w, r, "/web/vaults", http.StatusFound) return } s.renderSignIn(w, r, "") } // preCookie carries a token that the sign-in form must echo. Without it a // page elsewhere could post the form with its own credentials and sign a // visitor in to the attacker's account, where whatever they then create // lands. Strict, so a cross-site post does not carry it. const preCookie = "keeporg_pre" // preSessionToken returns this browser's sign-in form token, issuing one if // it has none. func (s *Server) preSessionToken(w http.ResponseWriter, r *http.Request) string { if c, err := r.Cookie(preCookie); err == nil && len(c.Value) == 64 { return c.Value } tok := secret.Token() http.SetCookie(w, &http.Cookie{ Name: preCookie, Value: tok, Path: "/web", HttpOnly: true, Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"), SameSite: http.SameSiteStrictMode, }) return tok } func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { fail(w, http.StatusBadRequest, "bad_request", err.Error()) return } if c, err := r.Cookie(preCookie); err != nil || !secret.Equal(c.Value, r.FormValue("pre")) { s.renderSignIn(w, r, "The sign-in form had expired. Sign in again.") return } var token string err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"), func(st *store.State, u *store.User) error { token = s.newBrowserSession(st, u.ID, r).Token return nil }) var throttled *errThrottled switch { case errors.As(err, &throttled): s.renderSignIn(w, r, throttled.Error()) case errors.Is(err, errTOTPRequired): s.renderSignIn(w, r, "This account needs its one-time code.") case errors.Is(err, errTOTPInvalid): s.renderSignIn(w, r, "That one-time code was rejected.") case errors.Is(err, errBadCredentials): s.renderSignIn(w, r, "Unknown user or wrong password.") case err != nil: s.renderSignIn(w, r, err.Error()) default: s.setWebCookie(w, token, s.cfg.SessionTTL) http.Redirect(w, r, "/web/vaults", http.StatusFound) } } func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request, _ *webSession) { if c, err := r.Cookie(webCookie); err == nil { _ = s.st.Update(func(st *store.State) error { delete(st.Sessions, c.Value) return nil }) } s.setWebCookie(w, "", -time.Second) http.Redirect(w, r, "/web", http.StatusFound) } // webVault is one row of the vault list. type webVault struct { ID string Name string Perm string Strategy string Version int64 Updated string By string HasKey bool } // webGroup is one option in a group selector. type webGroup struct { ID string Name string } func (s *Server) handleWebVaults(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderVaults(w, sess, "", "") } func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, sess *webSession) { password := r.FormValue("password") if password != r.FormValue("password2") { s.renderVaults(w, sess, "The two vault passwords do not match.", "") return } strategy := store.Strategy(r.FormValue("strategy")) if strategy == "" { strategy = store.StrategyMerge } view, err := s.createVault(sess.user, r.FormValue("name"), r.FormValue("group"), strategy, password) if err != nil { s.renderVaults(w, sess, humanError(err), "") return } s.renderVaults(w, sess, "", "Created "+view.Name+". Open "+view.Name+".kdbx from your KeepOrg client "+ "with the password you just set.") } // webDeleteVault is what the confirmation page shows before a vault goes. type webDeleteVault struct { ID string Name string Group string Version int64 Updated string Grants int // NeedsName marks an account with no password of its own, which confirms // by typing the vault's name instead; NeedsTOTP marks one with a second // factor enrolled. NeedsName bool NeedsTOTP bool } // deleteVaultView gathers what is about to be destroyed, or nil when the // caller does not administer the vault. func (s *Server) deleteVaultView(u *store.User, id string) *webDeleteVault { var out *webDeleteVault s.st.Read(func(st *store.State) { db := st.Databases[id] if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin { return } v := &webDeleteVault{ID: db.ID, Name: db.Name, Version: db.Version, Updated: "never"} if db.Version > 0 { v.Updated = db.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC") } if g := st.Groups[db.OwnerGroupID]; g != nil { v.Group = g.Name } for _, gr := range st.Grants { if gr.DatabaseID == id { v.Grants++ } } if target := st.Users[u.ID]; target != nil { v.NeedsName = target.PassHash == "" v.NeedsTOTP = target.TOTPConfirmed } out = v }) return out } func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request, sess *webSession) { s.renderDeleteVault(w, sess, r.PathValue("id"), "") } func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) { id := r.PathValue("id") name, err := s.deleteDatabase(r, sess.user, id, r.FormValue("password"), r.FormValue("totp"), r.FormValue("confirm_name")) var throttled *errThrottled switch { case errors.As(err, &throttled): s.renderDeleteVault(w, sess, id, throttled.Error()) case errors.Is(err, errReauth): s.renderDeleteVault(w, sess, id, "Enter your account password.") case errors.Is(err, errTOTPRequired): s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.") case errors.Is(err, errBadCredentials): s.renderDeleteVault(w, sess, id, "Your account password or one-time code is wrong.") case errors.Is(err, errConfirmName): s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.") case err != nil: s.renderDeleteVault(w, sess, id, humanError(err)) default: s.renderVaults(w, sess, "", "Deleted "+name+" and every version of it. "+ "Copies already on people's computers stay where they are.") } } // humanError keeps internal wording out of the browser. func humanError(err error) string { if errors.Is(err, errForbidden) { return "You do not administer that." } return err.Error() } // vaultRows collects what the signed-in user may see, and the groups they // may create a vault in. func (s *Server) vaultRows(u *store.User) ([]webVault, []webGroup) { vaults := []webVault{} groups := []webGroup{} s.st.Read(func(st *store.State) { for _, a := range st.Assignments(u.ID) { updated := "never" if a.DB.Version > 0 { updated = a.DB.UpdatedAt.UTC().Format("2006-01-02 15:04 UTC") } vaults = append(vaults, webVault{ ID: a.DB.ID, Name: a.DB.Name, Perm: string(a.Perm), Strategy: string(a.DB.MergeStrategy), Version: a.DB.Version, Updated: updated, By: a.DB.UpdatedBy, HasKey: a.DB.WrappedKey != "", }) } for id, g := range st.Groups { if st.IsGroupAdmin(u.ID, id) { groups = append(groups, webGroup{ID: id, Name: g.Name}) } } }) sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) return vaults, groups }