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 } // 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 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} } }) 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, "") 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, "") 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, "") } 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 } var token string err := s.st.Update(func(st *store.State) error { u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp")) if err != nil { return err } token = s.newSession(st, u.ID, "web").Token return nil }) switch { case errors.Is(err, errTOTPRequired): s.renderSignIn(w, "This account needs its one-time code.") case errors.Is(err, errTOTPInvalid): s.renderSignIn(w, "That one-time code was rejected.") case errors.Is(err, errBadCredentials): s.renderSignIn(w, "Unknown user or wrong password.") case err != nil: s.renderSignIn(w, 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.") } func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request, sess *webSession) { next := r.FormValue("new_password") if next != r.FormValue("new_password2") { s.renderVaults(w, sess, "The two new passwords do not match.", "") return } cookie, _ := r.Cookie(webCookie) keep := "" if cookie != nil { keep = cookie.Value } err := s.st.Update(func(st *store.State) error { return changePassword(st, sess.user.Username, r.FormValue("current_password"), r.FormValue("totp"), next, keep) }) switch { case errors.Is(err, errTOTPRequired): s.renderVaults(w, sess, "Enter your one-time code to change the password.", "") case errors.Is(err, errTOTPInvalid): s.renderVaults(w, sess, "That one-time code was rejected.", "") case errors.Is(err, errBadCredentials): s.renderVaults(w, sess, "The current password is wrong.", "") case err != nil: s.renderVaults(w, sess, err.Error(), "") default: s.renderVaults(w, sess, "", "Password changed. Other sessions on this account were signed out.") } } // 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 }