Add the administration pages to the web interface
Running the server meant running the API by hand: invites, accounts,
groups and grants existed only as endpoints and CLI commands, so the
browser could create a vault and nothing else.
/web/groups walks the group tree and opens onto one group's members,
roles and vault access, including the grants it inherits from above and
where they come from. /web/users lists the accounts you may see, and
lets a server administrator disable, enable, clear a lost second
factor, promote, demote or delete one. /web/invites shows the
outstanding links with their use counts, issues new ones and revokes
them.
Every administrative change is now a function of the state and the
caller in admin.go, which both the JSON handlers and the forms call. The
alternative was a second copy of the permission rules behind the pages,
which would have drifted on the first change. The same move gave the API
the two verbs it was missing: DELETE /api/v1/invites/{token} and
PATCH /api/v1/users/{id}.
Account changes are server-administrator only. A group administrator
decides who reaches which vault; disabling an account or clearing
somebody's second factor reaches past the group they share. Disable and
delete revoke that account's sessions, and the three changes that would
lock the caller out are refused on their own account.
Pages go through webPage and forms through webForm, so the session and
CSRF checks cannot be forgotten on a new form. No page carries any
JavaScript.
Server 0.6.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3a499532f3
commit
81094c0228
+43
-41
@@ -11,9 +11,10 @@ import (
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// The web interface exists for one job the command line cannot do well:
|
||||
// creating a vault and setting its password in a browser. It is not a
|
||||
// password manager and never shows a vault's contents; the server does not
|
||||
// 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"
|
||||
|
||||
@@ -44,6 +45,39 @@ func (s *Server) webUser(r *http.Request) *webSession {
|
||||
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"))
|
||||
@@ -99,16 +133,7 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSignOut(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
http.Redirect(w, r, "/web", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
|
||||
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
|
||||
return
|
||||
}
|
||||
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)
|
||||
@@ -131,31 +156,17 @@ type webVault struct {
|
||||
HasKey bool
|
||||
}
|
||||
|
||||
// webGroup is one option in the owning-group selector.
|
||||
// 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 := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
return
|
||||
}
|
||||
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 := 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
|
||||
}
|
||||
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.", "")
|
||||
@@ -176,16 +187,7 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) {
|
||||
"with the password you just set.")
|
||||
}
|
||||
|
||||
func (s *Server) handleWebChangePassword(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
|
||||
}
|
||||
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.", "")
|
||||
@@ -218,7 +220,7 @@ func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request)
|
||||
// humanError keeps internal wording out of the browser.
|
||||
func humanError(err error) string {
|
||||
if errors.Is(err, errForbidden) {
|
||||
return "You do not administer that group."
|
||||
return "You do not administer that."
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user