Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync

Why: collaborative KeePass use needs one arbiter of "who wrote last" that
never sees a master password, plus access control that follows an org chart.

What: stdlib-only Go server. Metadata is a single JSON document with one
writer and atomic commit; KDBX payloads are versioned blobs on disk. Access
comes from a group tree where a grant on a group reaches every member below
it. Login is password+TOTP, invite links, or OIDC through a browser-approved
device flow. Pushes carry If-Match and are arbitrated by a per-database merge
strategy (server-wins by default). Updates are pushed to clients over SSE.

Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and
isolation, store rollback, blob pruning, every push verdict, and an
end-to-end HTTP pass over login, conflict, force, read-only grants, invite
redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
2026-10-29 15:54:10 +01:00
commit 8bbb3179a1
26 changed files with 3897 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
package server
import (
"errors"
"net/http"
"strings"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// MinPasswordLen is the shortest password the server will store.
const MinPasswordLen = 10
// deviceTTL bounds how long a pending device login stays open.
const deviceTTL = 10 * time.Minute
var (
errBadCredentials = errors.New("bad credentials")
errTOTPRequired = errors.New("totp required")
errTOTPInvalid = errors.New("totp invalid")
)
// checkPassword validates a username, password and second factor against the
// state. It is the one place password login is decided.
func checkPassword(st *store.State, username, password, code string) (*store.User, error) {
u := st.UserByName(username)
if u == nil || u.Disabled || u.PassHash == "" {
return nil, errBadCredentials
}
if !secret.Verify(u.PassHash, password) {
return nil, errBadCredentials
}
if u.TOTPConfirmed {
if strings.TrimSpace(code) == "" {
return nil, errTOTPRequired
}
if !totp.Check(u.TOTPSecret, code, time.Now()) {
return nil, errTOTPInvalid
}
}
return u, nil
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTP string `json:"totp"`
Label string `json:"label"`
}
type loginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
User userView `json:"user"`
}
type userView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
}
func view(u *store.User) userView {
return userView{ID: u.ID, Username: u.Username, Email: u.Email,
ServerAdmin: u.ServerAdmin, TOTPEnabled: u.TOTPConfirmed}
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if !readJSON(w, r, &req) {
return
}
var resp loginResponse
err := s.st.Update(func(st *store.State) error {
u, err := checkPassword(st, req.Username, req.Password, req.TOTP)
if err != nil {
return err
}
sess := s.newSession(st, u.ID, req.Label)
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
return nil
})
switch {
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials", "unknown user or wrong password")
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
default:
writeJSON(w, http.StatusOK, resp)
}
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, _ *store.User) {
tok := bearer(r)
if err := s.st.Update(func(st *store.State) error {
delete(st.Sessions, tok)
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.User) {
type groupView struct {
ID string `json:"id"`
Name string `json:"name"`
Role store.Role `json:"role"`
}
out := struct {
userView
Groups []groupView `json:"groups"`
}{userView: view(u), Groups: []groupView{}}
s.st.Read(func(st *store.State) {
for _, m := range st.Memberships {
if m.UserID != u.ID {
continue
}
if g := st.Groups[m.GroupID]; g != nil {
out.Groups = append(out.Groups, groupView{ID: g.ID, Name: g.Name, Role: m.Role})
}
}
})
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
sec := totp.NewSecret()
if err := s.st.Update(func(st *store.State) error {
target := st.Users[u.ID]
if target == nil {
return errBadCredentials
}
target.TOTPSecret = sec
target.TOTPConfirmed = false
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"secret": sec,
"uri": totp.URI("KeepOrg", u.Username, sec),
})
}
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
Code string `json:"code"`
}
if !readJSON(w, r, &req) {
return
}
err := s.st.Update(func(st *store.State) error {
target := st.Users[u.ID]
if target == nil || target.TOTPSecret == "" {
return errBadCredentials
}
if !totp.Check(target.TOTPSecret, req.Code, time.Now()) {
return errTOTPInvalid
}
target.TOTPConfirmed = true
return nil
})
switch {
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
default:
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
}
}
// ---- device login: the browser approves, the client polls ----
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
var req struct {
Label string `json:"label"`
}
if r.ContentLength > 0 && !readJSON(w, r, &req) {
return
}
d := &store.DeviceAuth{
DeviceCode: secret.Token(),
UserCode: secret.UserCode(),
Label: req.Label,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(deviceTTL),
}
if err := s.st.Update(func(st *store.State) error {
st.Devices[d.DeviceCode] = d
return nil
}); err != nil {
fail(w, http.StatusInternalServerError, "internal", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"device_code": d.DeviceCode,
"user_code": d.UserCode,
"verification_url": s.cfg.ExternalURL + "/device?code=" + d.UserCode,
"interval": 2,
"expires_in": int(deviceTTL.Seconds()),
})
}
func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceCode string `json:"device_code"`
}
if !readJSON(w, r, &req) {
return
}
var (
token string
userID string
gone bool
)
err := s.st.Update(func(st *store.State) error {
d := st.Devices[req.DeviceCode]
if d == nil || time.Now().After(d.ExpiresAt) {
delete(st.Devices, req.DeviceCode)
gone = true
return nil
}
if d.SessionToken == "" {
return nil
}
token = d.SessionToken
if sess := st.Sessions[token]; sess != nil {
userID = sess.UserID
}
delete(st.Devices, req.DeviceCode)
return nil
})
switch {
case err != nil:
fail(w, http.StatusInternalServerError, "internal", err.Error())
case gone:
fail(w, http.StatusGone, "expired", "device code expired")
case token == "":
writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"})
default:
var resp loginResponse
s.st.Read(func(st *store.State) {
resp.Token = token
if sess := st.Sessions[token]; sess != nil {
resp.ExpiresAt = sess.ExpiresAt
}
if u := st.Users[userID]; u != nil {
resp.User = view(u)
}
})
writeJSON(w, http.StatusOK, resp)
}
}
func (s *Server) handleDevicePage(w http.ResponseWriter, r *http.Request) {
s.renderDevice(w, r.URL.Query().Get("code"), "")
}
func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
err := s.st.Update(func(st *store.State) error {
d := st.DeviceByUserCode(code)
if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired code")
}
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"))
if err != nil {
return err
}
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
return nil
})
switch {
case errors.Is(err, errTOTPRequired):
s.renderDevice(w, code, "This account requires a one-time code.")
case errors.Is(err, errTOTPInvalid):
s.renderDevice(w, code, "That one-time code was rejected.")
case errors.Is(err, errBadCredentials):
s.renderDevice(w, code, "Unknown user or wrong password.")
case err != nil:
s.renderDevice(w, code, err.Error())
default:
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
}
}
// ---- invites ----
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
tok := r.PathValue("token")
var ok bool
s.st.Read(func(st *store.State) {
if inv := st.Invites[tok]; inv != nil && inv.Usable(time.Now()) {
ok = true
}
})
if !ok {
s.renderDone(w, "Invite not valid", "This invite link has expired or has already been used.")
return
}
s.renderInvite(w, tok, "")
}
func (s *Server) handleInviteRedeem(w http.ResponseWriter, r *http.Request) {
tok := r.PathValue("token")
if err := r.ParseForm(); err != nil {
fail(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
err := s.st.Update(func(st *store.State) error {
inv := st.Invites[tok]
if inv == nil || !inv.Usable(time.Now()) {
return errors.New("this invite link has expired or has already been used")
}
if err := validUsername(username); err != nil {
return err
}
if len(password) < MinPasswordLen {
return errors.New("password must be at least 10 characters")
}
if password != r.FormValue("password2") {
return errors.New("the two passwords do not match")
}
if st.UserByName(username) != nil {
return errors.New("that username is taken")
}
hash, err := secret.Hash(password)
if err != nil {
return err
}
u := &store.User{ID: secret.ID(), Username: username, Email: strings.TrimSpace(r.FormValue("email")),
PassHash: hash, CreatedAt: time.Now()}
st.Users[u.ID] = u
if inv.GroupID != "" {
role := inv.Role
if !role.Valid() {
role = store.RoleMember
}
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: inv.GroupID, Role: role})
}
inv.Uses++
return nil
})
if err != nil {
s.renderInvite(w, tok, err.Error())
return
}
s.renderDone(w, "Account created",
"Sign in from the KeepOrg client with your new username and password.")
}
func validUsername(name string) error {
if len(name) < 2 || len(name) > 64 {
return errors.New("username must be between 2 and 64 characters")
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '.', r == '-', r == '_', r == '@':
default:
return errors.New("username may contain letters, digits and . - _ @ only")
}
}
return nil
}