Files
keeporg/internal/server/web.go
T
ReactorcoremeltdownandClaude Opus 5.5 f8f634fbf7 Add an Account page for sign-in methods and signed-in devices
/web/account, offered to everyone, lists every browser and client signed
in as you, with when each was last used, and signs any of them out, or all
but the current one. It sets up, replaces and removes the second factor,
and holds the password form, moved from the vault page. Administrators can
sign a user out everywhere from the People page.

The API gains GET and DELETE /api/v1/auth/sessions and DELETE
/api/v1/auth/totp. Sessions are named by a digest of their token, never the
token, and record when they were last used. Replacing or removing an active
second factor now needs the password and a current code.

Server version 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:37:47 +01:00

302 lines
8.6 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
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, "")
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.newBrowserSession(st, u.ID, r).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.")
}
// 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(sess.user, id, r.FormValue("password"),
r.FormValue("totp"), r.FormValue("confirm_name"))
switch {
case errors.Is(err, errTOTPRequired):
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
case errors.Is(err, errTOTPInvalid):
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderDeleteVault(w, sess, id, "That is not your account password.")
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
}