Files
keeporg/internal/server/web.go
T
ReactorcoremeltdownandClaude Opus 5 81094c0228 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>
2026-10-30 12:32:08 +01:00

254 lines
7.2 KiB
Go

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
}