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,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
|
||||
}
|
||||
Reference in New Issue
Block a user