ci/woodpecker/push/build Pipeline was successful
The web interface could create a vault and never remove one, so the only
way to undo a mistake was the CLI. There is now a Delete link on every
vault you administer, leading to a page that says what is about to go —
owner, versions stored, grants pointing at it — and asks for your
account password before it goes.
The gate is re-authentication, not a confirmation checkbox. A session
token is enough to read and write a vault; it should not be enough to
erase every version of one, because a session is what an unlocked
laptop leaves lying around. The one-time code is required too when the
account has one enrolled.
It lives in confirmDeletion, so the rule holds wherever deletion is
reachable: DELETE /api/v1/databases/{id} now takes {password, totp?}
and answers 401 without it, and keeporg db -delete prompts. That is a
breaking change to the endpoint, which is why the server goes to 0.7.0
and the desktop clients to 0.4.0.
An account that signs in only through an identity provider has no
password to type. Rather than lock those deployments out of deleting
anything, they confirm by typing the vault's exact name — the weaker
gate, and deliberately the fallback.
Deleting takes the record, its grants, every stored version and the key
a merge vault left with the server, and tells the watchers over SSE.
Copies already in people's mirror directories stay where they are: the
server stops tracking a vault, it does not reach into anybody's home
directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
324 lines
9.5 KiB
Go
324 lines
9.5 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.")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|