Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
330 lines
11 KiB
Go
330 lines
11 KiB
Go
// 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/kdbx"
|
|
"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"`
|
|
|
|
// MasterKeyFile holds the key that wraps the vault keys the server keeps
|
|
// in order to merge. It belongs outside DataDir: a copy of the data
|
|
// directory alone must not be enough to open a vault.
|
|
MasterKeyFile string `json:"master_key_file"`
|
|
// VaultKDF is the work factor of vaults this server creates. The server
|
|
// pays it three times per merge, and so does every client on every open.
|
|
VaultKDF kdbx.KDFParams `json:"vault_kdf"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if c.MasterKeyFile == "" {
|
|
c.MasterKeyFile = "/etc/keeporg/master.key"
|
|
}
|
|
if c.VaultKDF.MemoryKiB == 0 {
|
|
c.VaultKDF = kdbx.DefaultKDF
|
|
}
|
|
}
|
|
|
|
type Server struct {
|
|
cfg Config
|
|
st *store.Store
|
|
blobs *blob.Store
|
|
hub *Hub
|
|
oidc *oidcProvider
|
|
keyring *secret.Keyring
|
|
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}
|
|
// Without a master key the server still serves every strategy that does
|
|
// not need to read a vault; only merging and vault creation are refused.
|
|
if kr, kerr := secret.LoadKeyring(cfg.MasterKeyFile); kerr != nil {
|
|
lg.Printf("no master key, so this server cannot create or merge vaults: %v", kerr)
|
|
} else {
|
|
s.keyring = kr
|
|
}
|
|
if cfg.OIDC.Enabled() {
|
|
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
|
|
}
|
|
s.checkVaultKeys()
|
|
s.routes()
|
|
return s, nil
|
|
}
|
|
|
|
// checkVaultKeys reports at startup whether the master key still opens the
|
|
// vault keys in the state document. Getting this wrong is quiet and
|
|
// expensive: a container started without its master key mounted mints a new
|
|
// one, and every merge then fails one push at a time. Saying so once, at
|
|
// startup, is the difference between a puzzle and a fixable mistake.
|
|
func (s *Server) checkVaultKeys() {
|
|
var total, broken int
|
|
s.st.Read(func(st *store.State) {
|
|
for id, d := range st.Databases {
|
|
if d.WrappedKey == "" {
|
|
continue
|
|
}
|
|
total++
|
|
if s.keyring == nil {
|
|
broken++
|
|
continue
|
|
}
|
|
if _, err := s.keyring.Unwrap(id, d.WrappedKey); err != nil {
|
|
broken++
|
|
}
|
|
}
|
|
})
|
|
switch {
|
|
case broken == 0 && total > 0:
|
|
s.log.Printf("%d vault keys available for merging", total)
|
|
case broken > 0:
|
|
s.log.Printf("WARNING: %d of %d stored vault keys do not open with this "+
|
|
"master key. Those vaults cannot be merged until the original key is "+
|
|
"restored ($%s, $%s, or %s). Nothing is lost: clients still open the "+
|
|
"vaults with their own passwords.",
|
|
broken, total, secret.MasterKeyEnv, secret.MasterKeyFileEnv,
|
|
s.cfg.MasterKeyFile)
|
|
}
|
|
}
|
|
|
|
// 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(s.handleLogout))
|
|
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
|
m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword))
|
|
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
|
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
|
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
|
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
|
|
|
// The web interface: sign in, see your vaults, create one.
|
|
m.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/web", http.StatusFound)
|
|
})
|
|
m.HandleFunc("GET /web", s.handleWebHome)
|
|
m.HandleFunc("POST /web/signin", s.handleWebSignIn)
|
|
m.HandleFunc("POST /web/signout", s.handleWebSignOut)
|
|
m.HandleFunc("GET /web/vaults", s.handleWebVaults)
|
|
m.HandleFunc("POST /web/vaults", s.handleWebCreateVault)
|
|
m.HandleFunc("POST /web/password", s.handleWebChangePassword)
|
|
|
|
// 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(s.handleListDatabases))
|
|
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob))
|
|
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob))
|
|
m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents))
|
|
|
|
// Vaults: a database the server created, and can merge.
|
|
m.HandleFunc("POST /api/v1/vaults", s.authed(s.handleCreateVault))
|
|
m.HandleFunc("POST /api/v1/databases/{id}/key", s.authed(s.handleSetVaultKey))
|
|
|
|
// Administration.
|
|
m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase))
|
|
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase))
|
|
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase))
|
|
m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups))
|
|
m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup))
|
|
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup))
|
|
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember))
|
|
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember))
|
|
m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant))
|
|
m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
|
|
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
|
|
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites))
|
|
m.HandleFunc("GET /api/v1/users", s.authed(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 permission is then checked inside the handler, where the object
|
|
// being acted on is known.
|
|
func (s *Server) authed(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,
|
|
CSRF: secret.Token(),
|
|
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(),
|
|
})
|
|
}
|