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:
@@ -0,0 +1,524 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
var errForbidden = errors.New("forbidden")
|
||||
|
||||
// deny maps a domain error onto a response.
|
||||
func deny(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, errForbidden):
|
||||
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
|
||||
default:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- databases ----
|
||||
|
||||
func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
OwnerGroupID string `json:"owner_group_id"`
|
||||
MergeStrategy store.Strategy `json:"merge_strategy"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
var out dbView
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if st.Groups[req.OwnerGroupID] == nil {
|
||||
return errors.New("unknown owner group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) {
|
||||
return errForbidden
|
||||
}
|
||||
strategy := req.MergeStrategy
|
||||
if strategy == "" {
|
||||
strategy = store.StrategyServerWins
|
||||
}
|
||||
if !strategy.Valid() {
|
||||
return errors.New("unknown merge strategy")
|
||||
}
|
||||
db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
|
||||
OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()}
|
||||
st.Databases[db.ID] = db
|
||||
out = toView(db, store.PermAdmin)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
s.notifyList(out.ID)
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
id := r.PathValue("id")
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
MergeStrategy *store.Strategy `json:"merge_strategy"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
var out dbView
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
db := st.Databases[id]
|
||||
if db == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if req.Name != nil {
|
||||
if strings.TrimSpace(*req.Name) == "" {
|
||||
return errors.New("name may not be empty")
|
||||
}
|
||||
db.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.MergeStrategy != nil {
|
||||
if !req.MergeStrategy.Valid() {
|
||||
return errors.New("unknown merge strategy")
|
||||
}
|
||||
db.MergeStrategy = *req.MergeStrategy
|
||||
}
|
||||
out = toView(db, store.PermAdmin)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
s.notifyList(id)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
id := r.PathValue("id")
|
||||
var watchers []string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if st.Databases[id] == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
watchers = st.Watchers(id)
|
||||
delete(st.Databases, id)
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.blobs.Forget(id); err != nil {
|
||||
s.log.Printf("delete blobs for %s: %v", id, err)
|
||||
}
|
||||
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ---- groups and membership ----
|
||||
|
||||
type groupView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Admin bool `json:"admin"`
|
||||
Members int `json:"members"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
out := []groupView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
counts := map[string]int{}
|
||||
for _, m := range st.Memberships {
|
||||
counts[m.GroupID]++
|
||||
}
|
||||
reach := st.EffectiveGroups(u.ID)
|
||||
for id, g := range st.Groups {
|
||||
admin := st.IsGroupAdmin(u.ID, id)
|
||||
visible := admin || reach[id] || u.ServerAdmin
|
||||
if !visible {
|
||||
// A group below one the user administers is visible too.
|
||||
for _, anc := range st.Ancestors(id) {
|
||||
if st.IsGroupAdmin(u.ID, anc) {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID,
|
||||
Admin: admin, Members: counts[id]})
|
||||
}
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parent_id"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
var out groupView
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if req.ParentID == "" {
|
||||
if !u.ServerAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
} else {
|
||||
if st.Groups[req.ParentID] == nil {
|
||||
return errors.New("unknown parent group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, req.ParentID) {
|
||||
return errForbidden
|
||||
}
|
||||
}
|
||||
g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name),
|
||||
ParentID: req.ParentID, CreatedAt: time.Now()}
|
||||
st.Groups[g.ID] = g
|
||||
out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
id := r.PathValue("id")
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
g := st.Groups[id]
|
||||
if g == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, id) {
|
||||
return errForbidden
|
||||
}
|
||||
if len(st.Descendants(id)) > 1 {
|
||||
return errors.New("the group still has child groups")
|
||||
}
|
||||
for _, db := range st.Databases {
|
||||
if db.OwnerGroupID == id {
|
||||
return errors.New("the group still owns databases")
|
||||
}
|
||||
}
|
||||
for _, m := range st.Memberships {
|
||||
if m.GroupID == id {
|
||||
return errors.New("the group still has members")
|
||||
}
|
||||
}
|
||||
delete(st.Groups, id)
|
||||
st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id })
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
groupID := r.PathValue("id")
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
UserID string `json:"user_id"`
|
||||
Role store.Role `json:"role"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if st.Groups[groupID] == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, groupID) {
|
||||
return errForbidden
|
||||
}
|
||||
target := st.Users[req.UserID]
|
||||
if target == nil && req.Username != "" {
|
||||
target = st.UserByName(req.Username)
|
||||
}
|
||||
if target == nil {
|
||||
return errors.New("no such user")
|
||||
}
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = store.RoleMember
|
||||
}
|
||||
if !role.Valid() {
|
||||
return errors.New("role must be member or admin")
|
||||
}
|
||||
for i := range st.Memberships {
|
||||
if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID {
|
||||
st.Memberships[i].Role = role
|
||||
return nil
|
||||
}
|
||||
}
|
||||
st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
groupID, userRef := r.PathValue("id"), r.PathValue("user")
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if st.Groups[groupID] == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, groupID) {
|
||||
return errForbidden
|
||||
}
|
||||
target := st.Users[userRef]
|
||||
if target == nil {
|
||||
target = st.UserByName(userRef)
|
||||
}
|
||||
if target == nil {
|
||||
return errors.New("no such user")
|
||||
}
|
||||
out := st.Memberships[:0]
|
||||
for _, m := range st.Memberships {
|
||||
if m.UserID == target.ID && m.GroupID == groupID {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
st.Memberships = out
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
|
||||
}
|
||||
|
||||
// ---- grants ----
|
||||
|
||||
func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
GroupID string `json:"group_id"`
|
||||
DatabaseID string `json:"database_id"`
|
||||
Perm store.Perm `json:"perm"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
var watchers []string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if st.Databases[req.DatabaseID] == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.Groups[req.GroupID] == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if !req.Perm.Valid() {
|
||||
return errors.New("perm must be read, write or admin")
|
||||
}
|
||||
for i := range st.Grants {
|
||||
if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID {
|
||||
st.Grants[i].Perm = req.Perm
|
||||
watchers = st.Watchers(req.DatabaseID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID,
|
||||
DatabaseID: req.DatabaseID, Perm: req.Perm})
|
||||
watchers = st.Watchers(req.DatabaseID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
groupID := r.URL.Query().Get("group_id")
|
||||
dbID := r.URL.Query().Get("database_id")
|
||||
var watchers []string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if st.Databases[dbID] == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
watchers = st.Watchers(dbID)
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
|
||||
return g.GroupID != groupID || g.DatabaseID != dbID
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
GroupID string `json:"group_id"`
|
||||
Role store.Role `json:"role"`
|
||||
TTLHours int `json:"ttl_hours"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role,
|
||||
CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses}
|
||||
if inv.Role == "" {
|
||||
inv.Role = store.RoleMember
|
||||
}
|
||||
if req.TTLHours <= 0 {
|
||||
req.TTLHours = 168
|
||||
}
|
||||
if inv.MaxUses == 0 {
|
||||
inv.MaxUses = 1
|
||||
}
|
||||
inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour)
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if inv.GroupID == "" {
|
||||
if !u.ServerAdmin {
|
||||
return errors.New("group_id is required")
|
||||
}
|
||||
} else {
|
||||
if st.Groups[inv.GroupID] == nil {
|
||||
return errors.New("unknown group")
|
||||
}
|
||||
if !st.IsGroupAdmin(u.ID, inv.GroupID) {
|
||||
return errForbidden
|
||||
}
|
||||
}
|
||||
if !inv.Role.Valid() {
|
||||
return errors.New("role must be member or admin")
|
||||
}
|
||||
st.Invites[inv.Token] = inv
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
deny(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"token": inv.Token,
|
||||
"url": s.cfg.ExternalURL + "/invite/" + inv.Token,
|
||||
"expires_at": inv.ExpiresAt,
|
||||
"max_uses": inv.MaxUses,
|
||||
"group_id": inv.GroupID,
|
||||
"role": inv.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
type inviteView struct {
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Role store.Role `json:"role"`
|
||||
Uses int `json:"uses"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
out := []inviteView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, inv := range st.Invites {
|
||||
if !u.ServerAdmin && inv.CreatedBy != u.ID &&
|
||||
!(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token,
|
||||
GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses,
|
||||
ExpiresAt: inv.ExpiresAt})
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) })
|
||||
writeJSON(w, http.StatusOK, map[string]any{"invites": out})
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
out := []userView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
for id, target := range st.Users {
|
||||
if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) {
|
||||
out = append(out, view(target))
|
||||
}
|
||||
}
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// sharesAdministeredGroup reports whether admin administers any group the
|
||||
// target belongs to, which is what makes the target visible to them.
|
||||
func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool {
|
||||
for _, m := range st.Memberships {
|
||||
if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// notifyList tells everyone with access that the database list changed.
|
||||
func (s *Server) notifyList(dbID string) {
|
||||
var watchers []string
|
||||
s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) })
|
||||
s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID})
|
||||
}
|
||||
|
||||
func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant {
|
||||
out := in[:0]
|
||||
for _, g := range in {
|
||||
if keep(g) {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Event is one push notification sent to clients over SSE.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
DatabaseID string `json:"database_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version int64 `json:"version,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
EventUpdated = "db-updated"
|
||||
EventChanged = "db-list-changed"
|
||||
)
|
||||
|
||||
// subBuffer is how many events a slow subscriber may fall behind before its
|
||||
// stream is dropped. A dropped client reconnects and resyncs from scratch,
|
||||
// so losing events is safe; blocking the publisher would not be.
|
||||
const subBuffer = 16
|
||||
|
||||
type subscriber struct {
|
||||
userID string
|
||||
ch chan Event
|
||||
}
|
||||
|
||||
// Hub fans events out to the connected clients of specific users.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
next int
|
||||
subs map[int]*subscriber
|
||||
}
|
||||
|
||||
func NewHub() *Hub { return &Hub{subs: map[int]*subscriber{}} }
|
||||
|
||||
// Subscribe registers a stream for one user and returns its id and channel.
|
||||
func (h *Hub) Subscribe(userID string) (int, <-chan Event) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.next++
|
||||
s := &subscriber{userID: userID, ch: make(chan Event, subBuffer)}
|
||||
h.subs[h.next] = s
|
||||
return h.next, s.ch
|
||||
}
|
||||
|
||||
// Unsubscribe removes a stream and closes its channel.
|
||||
func (h *Hub) Unsubscribe(id int) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if s, ok := h.subs[id]; ok {
|
||||
delete(h.subs, id)
|
||||
close(s.ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish delivers ev to every stream of every listed user. A stream that is
|
||||
// not keeping up is dropped rather than waited for.
|
||||
func (h *Hub) Publish(userIDs []string, ev Event) {
|
||||
want := make(map[string]bool, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
want[id] = true
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for id, s := range h.subs {
|
||||
if !want[s.userID] {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.ch <- ev:
|
||||
default:
|
||||
delete(h.subs, id)
|
||||
close(s.ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count reports the number of live streams, for the health endpoint.
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
|
||||
func (e Event) encode() []byte {
|
||||
b, _ := json.Marshal(e)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
srv *Server
|
||||
http *httptest.Server
|
||||
}
|
||||
|
||||
const adminPassword = "admin-password-1"
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
srv, err := New(Config{DataDir: dir, ExternalURL: "https://keeporg.test"},
|
||||
log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := secret.Hash(adminPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true}
|
||||
st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"}
|
||||
st.Memberships = append(st.Memberships,
|
||||
store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
_ = filepath.Join(dir, "state.json")
|
||||
return &harness{t: t, srv: srv, http: ts}
|
||||
}
|
||||
|
||||
// call sends a JSON request and decodes the JSON response.
|
||||
func (h *harness) call(method, path, token string, body any) (int, map[string]any) {
|
||||
h.t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, h.http.URL+path, rdr)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]any{}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
func (h *harness) login(user, password, code string) (string, int, map[string]any) {
|
||||
h.t.Helper()
|
||||
status, body := h.call("POST", "/api/v1/auth/login", "",
|
||||
map[string]string{"username": user, "password": password, "totp": code, "label": "test"})
|
||||
tok, _ := body["token"].(string)
|
||||
return tok, status, body
|
||||
}
|
||||
|
||||
// push uploads bytes with an explicit If-Match and returns the response.
|
||||
func (h *harness) push(token, dbID, ifMatch string, data []byte) (int, map[string]any, http.Header) {
|
||||
h.t.Helper()
|
||||
req, err := http.NewRequest("PUT", h.http.URL+"/api/v1/databases/"+dbID+"/blob", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("If-Match", ifMatch)
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out := map[string]any{}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return resp.StatusCode, out, resp.Header
|
||||
}
|
||||
|
||||
// makeDB creates a database owned by the "everyone" group.
|
||||
func (h *harness) makeDB(token, name string) string {
|
||||
h.t.Helper()
|
||||
status, body := h.call("POST", "/api/v1/databases", token,
|
||||
map[string]string{"name": name, "owner_group_id": "everyone"})
|
||||
if status != http.StatusCreated {
|
||||
h.t.Fatalf("create database: %d %v", status, body)
|
||||
}
|
||||
return body["id"].(string)
|
||||
}
|
||||
|
||||
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if _, status, _ := h.login("admin", "nope", ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d", status)
|
||||
}
|
||||
tok, status, _ := h.login("admin", adminPassword, "")
|
||||
if status != http.StatusOK || tok == "" {
|
||||
t.Fatalf("status = %d token = %q", status, tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPullAndServerWinsConflict(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
db := h.makeDB(tok, "Team")
|
||||
|
||||
status, body, hdr := h.push(tok, db, `"0"`, []byte("kdbx-v1"))
|
||||
if status != http.StatusOK || body["version"].(float64) != 1 {
|
||||
t.Fatalf("first push: %d %v", status, body)
|
||||
}
|
||||
if hdr.Get("ETag") != `"1"` {
|
||||
t.Fatalf("etag = %q", hdr.Get("ETag"))
|
||||
}
|
||||
|
||||
// A second client that still believes it is on version 0 is refused, and
|
||||
// learns both the current version and the policy to apply.
|
||||
status, body, hdr = h.push(tok, db, `"0"`, []byte("kdbx-other"))
|
||||
if status != http.StatusConflict || body["code"] != "conflict" {
|
||||
t.Fatalf("stale push: %d %v", status, body)
|
||||
}
|
||||
if body["current_version"].(float64) != 1 || body["merge_strategy"] != "server-wins" {
|
||||
t.Fatalf("conflict body = %v", body)
|
||||
}
|
||||
if hdr.Get("X-KeepOrg-Strategy") != "server-wins" {
|
||||
t.Fatalf("strategy header = %q", hdr.Get("X-KeepOrg-Strategy"))
|
||||
}
|
||||
|
||||
// Forcing is refused while the strategy is server-wins.
|
||||
if status, _, _ = h.push(tok, db, "*", []byte("forced")); status != http.StatusConflict {
|
||||
t.Fatalf("force under server-wins: %d", status)
|
||||
}
|
||||
|
||||
// Re-pushing identical bytes is a no-op, so a save that changed nothing
|
||||
// does not burn a version.
|
||||
status, body, _ = h.push(tok, db, `"1"`, []byte("kdbx-v1"))
|
||||
if status != http.StatusOK || body["unchanged"] != true || body["version"].(float64) != 1 {
|
||||
t.Fatalf("idempotent push: %d %v", status, body)
|
||||
}
|
||||
|
||||
// Download returns exactly what was stored.
|
||||
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+db+"/blob", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
got, _ := io.ReadAll(resp.Body)
|
||||
if string(got) != "kdbx-v1" || resp.Header.Get("ETag") != `"1"` {
|
||||
t.Fatalf("download = %q etag %q", got, resp.Header.Get("ETag"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWinsAllowsForce(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
db := h.makeDB(tok, "Team")
|
||||
h.push(tok, db, `"0"`, []byte("v1"))
|
||||
|
||||
if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok,
|
||||
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusOK {
|
||||
t.Fatalf("patch strategy: %d %v", status, body)
|
||||
}
|
||||
status, body, _ := h.push(tok, db, "*", []byte("v2-forced"))
|
||||
if status != http.StatusOK || body["version"].(float64) != 2 {
|
||||
t.Fatalf("forced push: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyGrantCannotPush(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
db := h.makeDB(admin, "Team")
|
||||
|
||||
// A child group with a read-only grant, and a member invited into it.
|
||||
status, g := h.call("POST", "/api/v1/groups", admin,
|
||||
map[string]string{"name": "readers", "parent_id": "everyone"})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("create group: %d %v", status, g)
|
||||
}
|
||||
readers := g["id"].(string)
|
||||
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]string{
|
||||
"group_id": readers, "database_id": db, "perm": "read"}); status != http.StatusOK {
|
||||
t.Fatalf("grant: %d %v", status, body)
|
||||
}
|
||||
status, inv := h.call("POST", "/api/v1/invites", admin,
|
||||
map[string]any{"group_id": readers, "role": "member", "ttl_hours": 1, "max_uses": 1})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("invite: %d %v", status, inv)
|
||||
}
|
||||
token := inv["token"].(string)
|
||||
|
||||
form := url.Values{"username": {"reader"}, "password": {"reader-password"},
|
||||
"password2": {"reader-password"}}
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !strings.Contains(string(page), "Account created") {
|
||||
t.Fatalf("invite redemption page: %s", page)
|
||||
}
|
||||
|
||||
rtok, status, _ := h.login("reader", "reader-password", "")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("reader login: %d", status)
|
||||
}
|
||||
// The reader sees the database through the grant on its group...
|
||||
status, list := h.call("GET", "/api/v1/databases", rtok, nil)
|
||||
dbs := list["databases"].([]any)
|
||||
if status != http.StatusOK || len(dbs) != 1 {
|
||||
t.Fatalf("reader database list: %d %v", status, list)
|
||||
}
|
||||
if dbs[0].(map[string]any)["perm"] != "read" {
|
||||
t.Fatalf("reader perm = %v", dbs[0])
|
||||
}
|
||||
// ...but may not write it.
|
||||
if status, body, _ := h.push(rtok, db, `"0"`, []byte("nope")); status != http.StatusForbidden {
|
||||
t.Fatalf("reader push: %d %v", status, body)
|
||||
}
|
||||
// A second redemption of a single-use invite fails.
|
||||
resp2, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page2, _ := io.ReadAll(resp2.Body)
|
||||
resp2.Body.Close()
|
||||
if !strings.Contains(string(page2), "already been used") {
|
||||
t.Fatalf("reused invite page: %s", page2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStreamDeliversPush(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
db := h.makeDB(tok, "Team")
|
||||
|
||||
req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/events?access_token="+tok, nil)
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("content type = %q", ct)
|
||||
}
|
||||
|
||||
lines := make(chan string, 8)
|
||||
go func() {
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
lines <- sc.Text()
|
||||
}
|
||||
close(lines)
|
||||
}()
|
||||
// Wait for the stream to register before pushing.
|
||||
deadline := time.After(3 * time.Second)
|
||||
for h.srv.hub.Count() == 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("stream never registered")
|
||||
default:
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if status, body, _ := h.push(tok, db, `"0"`, []byte("v1")); status != http.StatusOK {
|
||||
t.Fatalf("push: %d %v", status, body)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case l, open := <-lines:
|
||||
if !open {
|
||||
t.Fatal("stream closed before the event arrived")
|
||||
}
|
||||
if strings.HasPrefix(l, "data: ") {
|
||||
var ev Event
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev.Type != EventUpdated || ev.DatabaseID != db || ev.Version != 1 {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for the push event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
|
||||
t.Fatalf("uri = %v", body["uri"])
|
||||
}
|
||||
code, err := totp.Code(sec, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", status, body)
|
||||
}
|
||||
if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized ||
|
||||
body["code"] != "totp_required" {
|
||||
t.Fatalf("login without code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
|
||||
t.Fatalf("login with code: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLoginApprovedInBrowser(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
deviceCode := start["device_code"].(string)
|
||||
userCode := start["user_code"].(string)
|
||||
if !strings.Contains(start["verification_url"].(string), userCode) {
|
||||
t.Fatalf("verification url = %v", start["verification_url"])
|
||||
}
|
||||
if status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": deviceCode}); status != http.StatusAccepted {
|
||||
t.Fatalf("poll before approval: %d %v", status, body)
|
||||
}
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{
|
||||
"code": {userCode}, "username": {"admin"}, "password": {adminPassword}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !strings.Contains(string(page), "Device approved") {
|
||||
t.Fatalf("approval page: %s", page)
|
||||
}
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": deviceCode})
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after approval: %d %v", status, body)
|
||||
}
|
||||
// The issued token works, and the device code is single use.
|
||||
if status, _ := h.call("GET", "/api/v1/auth/whoami", body["token"].(string), nil); status != http.StatusOK {
|
||||
t.Fatalf("whoami with device token: %d", status)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": deviceCode}); status != http.StatusGone {
|
||||
t.Fatalf("second poll: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAdminCannotAdminister(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
hash, _ := secret.Hash("outsider-password")
|
||||
if err := h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["out"] = &store.User{ID: "out", Username: "outsider", PassHash: hash}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := h.makeDB(admin, "Team")
|
||||
otok, _, _ := h.login("outsider", "outsider-password", "")
|
||||
|
||||
if status, _ := h.call("POST", "/api/v1/databases", otok,
|
||||
map[string]string{"name": "x", "owner_group_id": "everyone"}); status != http.StatusForbidden {
|
||||
t.Fatalf("outsider created a database: %d", status)
|
||||
}
|
||||
if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok,
|
||||
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden {
|
||||
t.Fatalf("outsider changed the strategy: %d", status)
|
||||
}
|
||||
// Without any grant the database is invisible, not merely unwritable.
|
||||
if status, _ := h.call("GET", "/api/v1/databases/"+db+"/blob", otok, nil); status != http.StatusNotFound {
|
||||
t.Fatalf("outsider read the blob: %d", status)
|
||||
}
|
||||
if status, _ := h.call("GET", "/api/v1/databases", "", nil); status != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous list: %d", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// OIDCConfig configures single sign-on. The server acts as a confidential
|
||||
// client: it exchanges the authorization code over TLS directly with the
|
||||
// provider's token endpoint and then reads the identity from the userinfo
|
||||
// endpoint, so no local JWT verification is needed.
|
||||
type OIDCConfig struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
Scopes string `json:"scopes"`
|
||||
// AutoCreate provisions an account on first successful sign-in.
|
||||
AutoCreate bool `json:"auto_create"`
|
||||
// DefaultGroupID is the group auto-created users join.
|
||||
DefaultGroupID string `json:"default_group_id"`
|
||||
}
|
||||
|
||||
func (c OIDCConfig) Enabled() bool {
|
||||
return c.Issuer != "" && c.ClientID != "" && c.ClientSecret != ""
|
||||
}
|
||||
|
||||
type oidcMetadata struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserinfoEndpoint string `json:"userinfo_endpoint"`
|
||||
}
|
||||
|
||||
type oidcProvider struct {
|
||||
cfg OIDCConfig
|
||||
redirectURI string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
meta *oidcMetadata
|
||||
}
|
||||
|
||||
func newOIDCProvider(cfg OIDCConfig, redirectURI string) *oidcProvider {
|
||||
if cfg.Scopes == "" {
|
||||
cfg.Scopes = "openid profile email"
|
||||
}
|
||||
return &oidcProvider{cfg: cfg, redirectURI: redirectURI,
|
||||
http: &http.Client{Timeout: 15 * time.Second}}
|
||||
}
|
||||
|
||||
// metadata fetches and caches the provider's discovery document.
|
||||
func (p *oidcProvider) metadata() (*oidcMetadata, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.meta != nil {
|
||||
return p.meta, nil
|
||||
}
|
||||
u := strings.TrimRight(p.cfg.Issuer, "/") + "/.well-known/openid-configuration"
|
||||
resp, err := p.http.Get(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc: discovery %s: %s", u, resp.Status)
|
||||
}
|
||||
var m oidcMetadata
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.AuthorizationEndpoint == "" || m.TokenEndpoint == "" || m.UserinfoEndpoint == "" {
|
||||
return nil, errors.New("oidc: discovery document is missing endpoints")
|
||||
}
|
||||
p.meta = &m
|
||||
return p.meta, nil
|
||||
}
|
||||
|
||||
type oidcIdentity struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// exchange turns an authorization code into the caller's identity.
|
||||
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
meta, err := p.metadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {p.redirectURI},
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(url.QueryEscape(p.cfg.ClientID), url.QueryEscape(p.cfg.ClientSecret))
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
|
||||
return nil, fmt.Errorf("oidc: token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var tok struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return nil, errors.New("oidc: token response carried no access token")
|
||||
}
|
||||
return p.userinfo(meta.UserinfoEndpoint, tok.AccessToken)
|
||||
}
|
||||
|
||||
func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := p.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("oidc: userinfo: %s", resp.Status)
|
||||
}
|
||||
var id oidcIdentity
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id.Subject == "" {
|
||||
return nil, errors.New("oidc: userinfo carried no subject")
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow for a pending device.
|
||||
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
|
||||
return
|
||||
}
|
||||
meta, err := s.oidc.metadata()
|
||||
if err != nil {
|
||||
s.log.Printf("oidc discovery: %v", err)
|
||||
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
|
||||
return
|
||||
}
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
return errors.New("unknown or expired device code")
|
||||
}
|
||||
d.OIDCState = state
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDevice(w, userCode, err.Error())
|
||||
return
|
||||
}
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {s.oidc.cfg.ClientID},
|
||||
"redirect_uri": {s.oidc.redirectURI},
|
||||
"scope": {s.oidc.cfg.Scopes},
|
||||
"state": {state},
|
||||
}
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOIDCCallback finishes the flow and approves the waiting device.
|
||||
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidc == nil {
|
||||
s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if e := q.Get("error"); e != "" {
|
||||
s.renderDone(w, "Sign-in failed", e)
|
||||
return
|
||||
}
|
||||
state, code := q.Get("state"), q.Get("code")
|
||||
if state == "" || code == "" {
|
||||
s.renderDone(w, "Sign-in failed", "The provider did not return a code.")
|
||||
return
|
||||
}
|
||||
// Bind the state to a pending device before spending the code.
|
||||
var found bool
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, d := range st.Devices {
|
||||
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if !found {
|
||||
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
|
||||
return
|
||||
}
|
||||
id, err := s.oidc.exchange(code)
|
||||
if err != nil {
|
||||
s.log.Printf("oidc exchange: %v", err)
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
if d.OIDCState != "" && secret.Equal(d.OIDCState, state) {
|
||||
dev = d
|
||||
}
|
||||
}
|
||||
if dev == nil || time.Now().After(dev.ExpiresAt) {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
u, err := s.linkOIDCUser(st, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
|
||||
}
|
||||
|
||||
// linkOIDCUser finds the account behind an identity, provisioning one when
|
||||
// the deployment allows it.
|
||||
func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, error) {
|
||||
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
|
||||
if u := st.UserByOIDC(issuer, id.Subject); u != nil {
|
||||
if u.Disabled {
|
||||
return nil, errors.New("this account is disabled")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
// A local account may claim the identity by matching username or email,
|
||||
// which is how an invited user upgrades to single sign-on.
|
||||
for _, u := range st.Users {
|
||||
if u.OIDCSubject != "" {
|
||||
continue
|
||||
}
|
||||
if (id.Username != "" && u.Username == id.Username) ||
|
||||
(id.Email != "" && strings.EqualFold(u.Email, id.Email)) {
|
||||
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
if !s.oidc.cfg.AutoCreate {
|
||||
return nil, errors.New("no account is linked to this identity; redeem an invite link first")
|
||||
}
|
||||
name := id.Username
|
||||
if name == "" {
|
||||
name = id.Email
|
||||
}
|
||||
if name == "" {
|
||||
name = "sso-" + secret.ID()
|
||||
}
|
||||
if validUsername(name) != nil || st.UserByName(name) != nil {
|
||||
name = "sso-" + secret.ID()
|
||||
}
|
||||
u := &store.User{ID: secret.ID(), Username: name, Email: id.Email,
|
||||
OIDCIssuer: issuer, OIDCSubject: id.Subject, CreatedAt: time.Now()}
|
||||
st.Users[u.ID] = u
|
||||
if g := s.oidc.cfg.DefaultGroupID; g != "" && st.Groups[g] != nil {
|
||||
st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: g, Role: store.RoleMember})
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The server has no browsable UI. These three pages exist only because a
|
||||
// browser is the right place to approve a device, redeem an invite and run an
|
||||
// OIDC round trip.
|
||||
var pages = template.Must(template.New("layout").Parse(`
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}} · KeepOrg</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fafafa;--card:#fff;--line:#d8d8d8;--accent:#2d6a4f}
|
||||
@media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
|
||||
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px}
|
||||
h1{font-size:1.25rem;margin:0 0 .25rem}
|
||||
p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem}
|
||||
label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem}
|
||||
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
|
||||
border:1px solid var(--line);border-radius:8px}
|
||||
button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
|
||||
background:var(--accent);border:0;border-radius:8px;cursor:pointer}
|
||||
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
|
||||
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
|
||||
code{font-size:1.1rem;letter-spacing:.08em}
|
||||
</style></head><body><main>{{template "body" .}}</main></body></html>{{end}}
|
||||
`))
|
||||
|
||||
var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>Approve this device</h1>
|
||||
<p class="sub">Sign in to let the client on your device sync your databases.</p>
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/device">
|
||||
<label for="code">Device code</label><input id="code" name="code" value="{{.Code}}" required autocapitalize="characters">
|
||||
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
|
||||
<button type="submit">Approve</button>
|
||||
</form>
|
||||
{{if .OIDC}}<a class="alt" href="/oidc/login?code={{.Code}}">Sign in with single sign-on →</a>{{end}}
|
||||
{{end}}`))
|
||||
|
||||
var inviteTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>Create your account</h1>
|
||||
<p class="sub">You were invited to a KeepOrg server.</p>
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/invite/{{.Token}}">
|
||||
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
|
||||
<label for="email">Email <small>(optional)</small></label><input id="email" name="email" type="email" autocomplete="email">
|
||||
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="10" required>
|
||||
<label for="password2">Repeat password</label><input id="password2" name="password2" type="password" autocomplete="new-password" minlength="10" required>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
{{end}}`))
|
||||
|
||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
|
||||
|
||||
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
|
||||
render(w, deviceTmpl, map[string]any{
|
||||
"Title": "Approve device", "Code": code, "Error": errMsg, "OIDC": s.oidc != nil,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
|
||||
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
|
||||
}
|
||||
|
||||
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
|
||||
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
|
||||
}
|
||||
|
||||
func render(w http.ResponseWriter, t *template.Template, data map[string]any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
_ = t.ExecuteTemplate(w, "layout", data)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package server implements the KeepOrg synchronization server: accounts,
|
||||
// nested groups, per-database grants, and the versioned KDBX sync endpoints.
|
||||
//
|
||||
// The server never opens a KDBX file. It moves opaque bytes and arbitrates
|
||||
// who wrote last, which keeps the master passwords entirely on the clients.
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// Config is the server's whole configuration surface.
|
||||
type Config struct {
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data_dir"`
|
||||
ExternalURL string `json:"external_url"`
|
||||
SessionTTL time.Duration `json:"session_ttl"`
|
||||
MaxDBSize int64 `json:"max_db_size"`
|
||||
TLSCert string `json:"tls_cert,omitempty"`
|
||||
TLSKey string `json:"tls_key,omitempty"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
}
|
||||
|
||||
// Defaults fills in every value a deployment may leave unset.
|
||||
func (c *Config) Defaults() {
|
||||
if c.Addr == "" {
|
||||
c.Addr = ":8765"
|
||||
}
|
||||
if c.DataDir == "" {
|
||||
c.DataDir = "/var/lib/keeporg"
|
||||
}
|
||||
if c.ExternalURL == "" {
|
||||
c.ExternalURL = "http://localhost" + c.Addr
|
||||
}
|
||||
c.ExternalURL = strings.TrimRight(c.ExternalURL, "/")
|
||||
if c.SessionTTL == 0 {
|
||||
c.SessionTTL = 90 * 24 * time.Hour
|
||||
}
|
||||
if c.MaxDBSize == 0 {
|
||||
c.MaxDBSize = 32 << 20
|
||||
}
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
st *store.Store
|
||||
blobs *blob.Store
|
||||
hub *Hub
|
||||
oidc *oidcProvider
|
||||
mux *http.ServeMux
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// New opens the server's data directory and wires up its routes.
|
||||
func New(cfg Config, lg *log.Logger) (*Server, error) {
|
||||
cfg.Defaults()
|
||||
st, err := store.Open(filepath.Join(cfg.DataDir, "state.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
if cfg.OIDC.Enabled() {
|
||||
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Store exposes the metadata store for bootstrap commands.
|
||||
func (s *Server) Store() *store.Store { return s.st }
|
||||
|
||||
// Config returns the effective configuration.
|
||||
func (s *Server) Config() Config { return s.cfg }
|
||||
|
||||
// Handler is the server's HTTP entry point.
|
||||
func (s *Server) Handler() http.Handler { return s.mux }
|
||||
|
||||
// StartHousekeeping runs periodic expiry until done is closed.
|
||||
func (s *Server) StartHousekeeping(done <-chan struct{}) {
|
||||
go func() {
|
||||
t := time.NewTicker(time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.st.GC(time.Now()); err != nil {
|
||||
s.log.Printf("housekeeping: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
m.HandleFunc("GET /healthz", s.handleHealth)
|
||||
|
||||
// Authentication.
|
||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
||||
|
||||
// Browser-facing pages: device approval, invite registration, OIDC.
|
||||
m.HandleFunc("GET /device", s.handleDevicePage)
|
||||
m.HandleFunc("POST /device", s.handleDeviceApprove)
|
||||
m.HandleFunc("GET /invite/{token}", s.handleInvitePage)
|
||||
m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem)
|
||||
m.HandleFunc("GET /oidc/login", s.handleOIDCLogin)
|
||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||
|
||||
// Synchronization.
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases))
|
||||
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob))
|
||||
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob))
|
||||
m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents))
|
||||
|
||||
// Administration.
|
||||
m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase))
|
||||
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase))
|
||||
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase))
|
||||
m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups))
|
||||
m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup))
|
||||
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember))
|
||||
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember))
|
||||
m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant))
|
||||
m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant))
|
||||
m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite))
|
||||
m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites))
|
||||
m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers))
|
||||
}
|
||||
|
||||
// ---- request plumbing ----
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"error"`
|
||||
}
|
||||
|
||||
var errUnauthorized = errors.New("unauthorized")
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, apiError{Code: code, Message: msg})
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handler is an authenticated request handler.
|
||||
type handler func(http.ResponseWriter, *http.Request, *store.User)
|
||||
|
||||
// authed resolves the bearer token to a live, enabled user before calling fn.
|
||||
// Per-object permissions are checked inside each handler, where the object is
|
||||
// known; min is accepted for documentation of the minimum account level.
|
||||
func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.userFromRequest(r)
|
||||
if err != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="keeporg"`)
|
||||
fail(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token")
|
||||
return
|
||||
}
|
||||
fn(w, r, u)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
tok := bearer(r)
|
||||
if tok == "" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
var out *store.User
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[tok]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
return
|
||||
}
|
||||
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
|
||||
cp := *u
|
||||
out = &cp
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
func bearer(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
sess := &store.Session{
|
||||
Token: secret.Token(),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(s.cfg.SessionTTL),
|
||||
}
|
||||
st.Sessions[sess.Token] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var users, dbs int
|
||||
s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) })
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok", "users": users, "databases": dbs, "streams": s.hub.Count(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// dbView is what a client learns about a database it has access to.
|
||||
type dbView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerGroupID string `json:"owner_group_id"`
|
||||
Perm store.Perm `json:"perm"`
|
||||
MergeStrategy store.Strategy `json:"merge_strategy"`
|
||||
Version int64 `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
func toView(db *store.Database, p store.Perm) dbView {
|
||||
return dbView{ID: db.ID, Name: db.Name, OwnerGroupID: db.OwnerGroupID, Perm: p,
|
||||
MergeStrategy: db.MergeStrategy, Version: db.Version, SHA256: db.SHA256,
|
||||
Size: db.Size, UpdatedAt: db.UpdatedAt, UpdatedBy: db.UpdatedBy}
|
||||
}
|
||||
|
||||
func (s *Server) handleListDatabases(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
out := []dbView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, a := range st.Assignments(u.ID) {
|
||||
out = append(out, toView(a.DB, a.Perm))
|
||||
}
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"databases": out})
|
||||
}
|
||||
|
||||
// handleGetBlob serves the current version of a database, or a historical one
|
||||
// when ?version= is given.
|
||||
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
id := r.PathValue("id")
|
||||
var (
|
||||
db store.Database
|
||||
perm store.Perm
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
perm = st.DatabasePerm(u.ID, id)
|
||||
if d := st.Databases[id]; d != nil {
|
||||
db = *d
|
||||
}
|
||||
})
|
||||
if !perm.AtLeast(store.PermRead) {
|
||||
fail(w, http.StatusNotFound, "not_found", "no such database")
|
||||
return
|
||||
}
|
||||
version := db.Version
|
||||
if q := r.URL.Query().Get("version"); q != "" {
|
||||
v, err := strconv.ParseInt(q, 10, 64)
|
||||
if err != nil {
|
||||
fail(w, http.StatusBadRequest, "bad_request", "version must be an integer")
|
||||
return
|
||||
}
|
||||
version = v
|
||||
}
|
||||
if version == 0 {
|
||||
// A database that has never been written has no bytes yet.
|
||||
w.Header().Set("ETag", `"0"`)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
data, err := s.blobs.Get(id, version)
|
||||
if err != nil {
|
||||
fail(w, http.StatusNotFound, "not_found", "that version is no longer stored")
|
||||
return
|
||||
}
|
||||
w.Header().Set("ETag", etag(version))
|
||||
w.Header().Set("X-KeepOrg-SHA256", blob.SHA256(data))
|
||||
w.Header().Set("X-KeepOrg-Version", strconv.FormatInt(version, 10))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", db.Name+".kdbx"))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// handlePutBlob stores a new version. The client states the version it edited
|
||||
// in If-Match; the stored merge strategy decides what happens if that is no
|
||||
// longer current.
|
||||
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
id := r.PathValue("id")
|
||||
var (
|
||||
perm store.Perm
|
||||
current int64
|
||||
strategy store.Strategy
|
||||
exists bool
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
perm = st.DatabasePerm(u.ID, id)
|
||||
if d := st.Databases[id]; d != nil {
|
||||
exists, current, strategy = true, d.Version, d.MergeStrategy
|
||||
}
|
||||
})
|
||||
if !exists || !perm.AtLeast(store.PermRead) {
|
||||
fail(w, http.StatusNotFound, "not_found", "no such database")
|
||||
return
|
||||
}
|
||||
if !perm.AtLeast(store.PermWrite) {
|
||||
fail(w, http.StatusForbidden, "forbidden", "you have read-only access to this database")
|
||||
return
|
||||
}
|
||||
v := decidePush(current, r.Header.Get("If-Match"), strategy)
|
||||
if !v.Accept {
|
||||
w.Header().Set("ETag", etag(current))
|
||||
w.Header().Set("X-KeepOrg-Strategy", string(strategy))
|
||||
writeJSON(w, v.Status, map[string]any{
|
||||
"code": v.Code, "error": v.Message,
|
||||
"current_version": current, "merge_strategy": strategy,
|
||||
})
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.cfg.MaxDBSize))
|
||||
if err != nil {
|
||||
fail(w, http.StatusRequestEntityTooLarge, "too_large",
|
||||
fmt.Sprintf("database exceeds the %d byte limit", s.cfg.MaxDBSize))
|
||||
return
|
||||
}
|
||||
if len(data) == 0 {
|
||||
fail(w, http.StatusBadRequest, "bad_request", "empty body")
|
||||
return
|
||||
}
|
||||
sum := blob.SHA256(data)
|
||||
if want := r.Header.Get("X-KeepOrg-SHA256"); want != "" && !strings.EqualFold(want, sum) {
|
||||
fail(w, http.StatusBadRequest, "checksum_mismatch", "body does not match the declared digest")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
next int64
|
||||
unchanged bool
|
||||
watchers []string
|
||||
name string
|
||||
)
|
||||
err = s.st.Update(func(st *store.State) error {
|
||||
d := st.Databases[id]
|
||||
if d == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
// Re-check under the write lock: a concurrent push may have landed.
|
||||
if vv := decidePush(d.Version, r.Header.Get("If-Match"), d.MergeStrategy); !vv.Accept {
|
||||
return conflictError{current: d.Version, strategy: d.MergeStrategy, verdict: vv}
|
||||
}
|
||||
if d.SHA256 == sum {
|
||||
next, unchanged, name = d.Version, true, d.Name
|
||||
return nil
|
||||
}
|
||||
next = d.Version + 1
|
||||
if err := s.blobs.Put(id, next, data); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Version, d.SHA256, d.Size = next, sum, int64(len(data))
|
||||
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
|
||||
name = d.Name
|
||||
watchers = st.Watchers(id)
|
||||
return nil
|
||||
})
|
||||
var ce conflictError
|
||||
switch {
|
||||
case errors.As(err, &ce):
|
||||
w.Header().Set("ETag", etag(ce.current))
|
||||
w.Header().Set("X-KeepOrg-Strategy", string(ce.strategy))
|
||||
writeJSON(w, ce.verdict.Status, map[string]any{
|
||||
"code": ce.verdict.Code, "error": ce.verdict.Message,
|
||||
"current_version": ce.current, "merge_strategy": ce.strategy,
|
||||
})
|
||||
return
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
if !unchanged {
|
||||
_ = s.blobs.Prune(id)
|
||||
s.hub.Publish(watchers, Event{Type: EventUpdated, DatabaseID: id, Name: name,
|
||||
Version: next, SHA256: sum, By: u.Username})
|
||||
}
|
||||
w.Header().Set("ETag", etag(next))
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"version": next, "sha256": sum, "unchanged": unchanged,
|
||||
})
|
||||
}
|
||||
|
||||
// handleEvents streams database updates to one client over SSE. SSE is enough
|
||||
// here because the channel only ever carries server-to-client notifications.
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
fail(w, http.StatusInternalServerError, "internal", "streaming unsupported")
|
||||
return
|
||||
}
|
||||
id, ch := s.hub.Subscribe(u.ID)
|
||||
defer s.hub.Unsubscribe(id)
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "retry: 3000\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
keepalive := time.NewTicker(25 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev, open := <-ch:
|
||||
if !open {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode())
|
||||
flusher.Flush()
|
||||
case <-keepalive.C:
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the push decision, kept pure ----
|
||||
|
||||
// verdict is the outcome of comparing a push against the stored version.
|
||||
type verdict struct {
|
||||
Accept bool
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
type conflictError struct {
|
||||
current int64
|
||||
strategy store.Strategy
|
||||
verdict verdict
|
||||
}
|
||||
|
||||
func (e conflictError) Error() string { return e.verdict.Message }
|
||||
|
||||
// decidePush is the whole merge policy. The client sends the version it based
|
||||
// its edit on in If-Match; If-Match: * means "I know I am stale and I intend
|
||||
// to win", which only a client-wins database allows.
|
||||
func decidePush(current int64, ifMatch string, strategy store.Strategy) verdict {
|
||||
ifMatch = strings.TrimSpace(ifMatch)
|
||||
switch {
|
||||
case ifMatch == "":
|
||||
return verdict{Status: http.StatusPreconditionRequired, Code: "precondition_required",
|
||||
Message: "If-Match must carry the version this edit was based on"}
|
||||
case ifMatch == "*":
|
||||
if strategy == store.StrategyClientWins {
|
||||
return verdict{Accept: true}
|
||||
}
|
||||
return verdict{Status: http.StatusConflict, Code: "force_not_allowed",
|
||||
Message: "this database does not allow a client to overwrite the server copy"}
|
||||
}
|
||||
v, err := strconv.ParseInt(strings.Trim(ifMatch, `"`), 10, 64)
|
||||
if err != nil {
|
||||
return verdict{Status: http.StatusBadRequest, Code: "bad_request",
|
||||
Message: "If-Match must be a version number or *"}
|
||||
}
|
||||
if v == current {
|
||||
return verdict{Accept: true}
|
||||
}
|
||||
return verdict{Status: http.StatusConflict, Code: "conflict",
|
||||
Message: fmt.Sprintf("the server is at version %d, this edit was based on %d", current, v)}
|
||||
}
|
||||
|
||||
func etag(v int64) string { return `"` + strconv.FormatInt(v, 10) + `"` }
|
||||
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
func TestDecidePushRequiresIfMatch(t *testing.T) {
|
||||
v := decidePush(3, "", store.StrategyServerWins)
|
||||
if v.Accept || v.Status != http.StatusPreconditionRequired {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecidePushAcceptsCurrentVersion(t *testing.T) {
|
||||
for _, m := range []string{`"3"`, "3", ` "3" `} {
|
||||
if v := decidePush(3, m, store.StrategyServerWins); !v.Accept {
|
||||
t.Fatalf("If-Match %q rejected: %+v", m, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecidePushRejectsStaleVersion(t *testing.T) {
|
||||
v := decidePush(4, `"3"`, store.StrategyServerWins)
|
||||
if v.Accept || v.Status != http.StatusConflict || v.Code != "conflict" {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForceOnlyAllowedForClientWins(t *testing.T) {
|
||||
if v := decidePush(4, "*", store.StrategyClientWins); !v.Accept {
|
||||
t.Fatalf("client-wins force rejected: %+v", v)
|
||||
}
|
||||
for _, s := range []store.Strategy{store.StrategyServerWins, store.StrategyManual} {
|
||||
v := decidePush(4, "*", s)
|
||||
if v.Accept || v.Code != "force_not_allowed" {
|
||||
t.Fatalf("force under %s = %+v", s, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecidePushRejectsJunkIfMatch(t *testing.T) {
|
||||
v := decidePush(1, `"abc"`, store.StrategyServerWins)
|
||||
if v.Accept || v.Status != http.StatusBadRequest {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user