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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
*.kdbx
|
||||||
|
*.deb
|
||||||
|
*.dmg
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
/android/.gradle/
|
||||||
|
/android/build/
|
||||||
|
/android/app/build/
|
||||||
|
/android/local.properties
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# General rules
|
||||||
|
|
||||||
|
Be absolutely frugal about spending remaining resources. Default to short, direct answers. Drop the opening pleasantries and the wrap-up summary. Lead with the answer, add detail only if it's needed, and stop when you're done.
|
||||||
|
|
||||||
|
# Naming and versioning
|
||||||
|
|
||||||
|
Applications shall use `cc.tiredsysadmin.<operating_system>.<application>` namespace for their unique identifiers. Versioning is done automatically each change according to semantic versioning policy. Track application versions of each platform accordingly (Android, macOS, Linux, BSD, Windows).
|
||||||
|
|
||||||
|
# Tools and building
|
||||||
|
|
||||||
|
Use Makefile as an entrypoint for building and publishing applications. Each target is atomic, construct dependencies.
|
||||||
|
|
||||||
|
For macOS versions always produce a DMG with a symlink to `/Applications` folder.
|
||||||
|
|
||||||
|
For Linux versions always produce a boilerplate for building .deb packages, also track changelog.
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
|
||||||
|
Write developer handover documentation explaining architecture and decisions.
|
||||||
|
|
||||||
|
# Publishing
|
||||||
|
|
||||||
|
Android applications shall be published to a private F-Droid repository at rcmd@garage.rcmd.space:/var/lib/fdroid/unsigned, and the application metadata must be published at rcmd@garage.rcmd.space:/var/lib/fdroid/metadata. Publishing is always a manual action, never automatic.
|
||||||
|
|
||||||
|
Android artifact naming scheme for publishing to unsigned directory is always `<application_id>_<version_code>.apk`
|
||||||
|
|
||||||
|
MacOS, Linux, and Windows versions keep builds locally.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
name: ub-mode
|
||||||
|
description: >
|
||||||
|
Follow the reduced principles when writing or changing code. Every change
|
||||||
|
follows First Make it Work, then Then Make it Right, then Follow Practice.
|
||||||
|
Use when the user runs /ub-mode or asks for the reduced principles.
|
||||||
|
---
|
||||||
|
|
||||||
|
# ub-mode
|
||||||
|
|
||||||
|
Every time you are asked to make a change follow:
|
||||||
|
* First Make it Work.
|
||||||
|
* Then Make it Right.
|
||||||
|
* Follow Practice
|
||||||
|
|
||||||
|
# First Make it Work
|
||||||
|
|
||||||
|
## Laziness Protocol
|
||||||
|
|
||||||
|
* One decision lives in one place.
|
||||||
|
* Ship the smallest edit that solves the problem.
|
||||||
|
|
||||||
|
## Foundational Thinking
|
||||||
|
|
||||||
|
* Get the data shape right before you write the logic.
|
||||||
|
* Do first whatever helps every later phase.
|
||||||
|
* If a concurrent writer would matter, do not share state.
|
||||||
|
|
||||||
|
## TDD
|
||||||
|
|
||||||
|
* Write the failing test before you change the code.
|
||||||
|
* See it fail for the reason you expect.
|
||||||
|
* Make the smallest change that makes it pass.
|
||||||
|
* If an honest test is impractical, do not write a bad one.
|
||||||
|
|
||||||
|
## Redesign From First Principles
|
||||||
|
|
||||||
|
* Do not bolt a new requirement onto the old design.
|
||||||
|
* Ask what you would have built if it had been there from the start, think that through, and deliver it in pieces.
|
||||||
|
|
||||||
|
## Attack the Premise
|
||||||
|
|
||||||
|
* When two fixes that share one premise fail the same gate, write that premise in one sentence and count which actors hold the imbalance.
|
||||||
|
* Remove the assignment.
|
||||||
|
* Do not start another fix until you have done that.
|
||||||
|
|
||||||
|
## Subtract Before You Add
|
||||||
|
|
||||||
|
* Take complexity out before you add anything.
|
||||||
|
|
||||||
|
## Outcome-Oriented Execution
|
||||||
|
|
||||||
|
* Aim at the end state you can check.
|
||||||
|
* A break in the middle is allowed when you planned it and can undo it.
|
||||||
|
|
||||||
|
## Build the Lever
|
||||||
|
|
||||||
|
* If the work is not obvious at a glance, build the tool that does it.
|
||||||
|
* Do one unit by hand, then make the tool match that unit.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Model the Domain
|
||||||
|
|
||||||
|
* Put the domain in a structure that makes the illegal states impossible.
|
||||||
|
* Maintain a glossary of domain terms.
|
||||||
|
* If the code is already clear and local, leave it.
|
||||||
|
|
||||||
|
### Boundary Discipline
|
||||||
|
|
||||||
|
* Check the data where it enters.
|
||||||
|
* Inside, trust it.
|
||||||
|
* Keep the business logic in a pure function.
|
||||||
|
|
||||||
|
### Type System Discipline
|
||||||
|
|
||||||
|
* Make the illegal value impossible to write.
|
||||||
|
* Parse outside data once, where it comes in.
|
||||||
|
* Do not silence the compiler.
|
||||||
|
|
||||||
|
### Make Operations Idempotent
|
||||||
|
|
||||||
|
* Running it twice, or running it after a crash halfway through, ends in the same state.
|
||||||
|
|
||||||
|
### Separate Before Serializing Shared State
|
||||||
|
|
||||||
|
* If they do not need the same mutable thing, give each its own.
|
||||||
|
* If they do, allow one writer, and enforce that in the structure.
|
||||||
|
|
||||||
|
# Then Make it Right
|
||||||
|
|
||||||
|
## Fix Root Causes
|
||||||
|
|
||||||
|
* Reproduce it.
|
||||||
|
* Fix the cause.
|
||||||
|
* A guard that only hides the crash is not a fix.
|
||||||
|
|
||||||
|
## Sequence work into verifiable units
|
||||||
|
|
||||||
|
* Finish one small unit and check it.
|
||||||
|
* Then start the next.
|
||||||
|
|
||||||
|
## Test Behavior, Not Implementation
|
||||||
|
|
||||||
|
* The test calls the code the way its user does and checks the result that user would see.
|
||||||
|
* If the test would still pass when every function returned nothing, throw it out.
|
||||||
|
|
||||||
|
## Crapper
|
||||||
|
|
||||||
|
* Use the crapper tool
|
||||||
|
* Reduce each function under CRAP 10 by splitting it, by testing it, or both.
|
||||||
|
* Consider whether case statements are worth reducing.
|
||||||
|
|
||||||
|
## Dryer
|
||||||
|
|
||||||
|
* Use the dryer tool to eliminate duplication that rank 0.8 or above.
|
||||||
|
|
||||||
|
## Mutator
|
||||||
|
|
||||||
|
* Use the mutator tool
|
||||||
|
* Cover the uncovered.
|
||||||
|
* Kill the survivors.
|
||||||
|
* Delete dead code.
|
||||||
|
|
||||||
|
## Refactoring
|
||||||
|
|
||||||
|
* Pin what the code does.
|
||||||
|
* Change the structure.
|
||||||
|
* Prove the behavior did not change.
|
||||||
|
|
||||||
|
# Practice
|
||||||
|
|
||||||
|
## Keep a Log
|
||||||
|
|
||||||
|
* Keep a log of every action you take and the reason you took it.
|
||||||
|
* Report that log upon completion.
|
||||||
|
|
||||||
|
## Guard the Context Window
|
||||||
|
|
||||||
|
* Keep the main thread small.
|
||||||
|
* Send the bulk elsewhere, and keep the summary.
|
||||||
|
|
||||||
|
## Worktree Cleanup
|
||||||
|
|
||||||
|
* Find what is safe to remove, confirm nothing still in use needs it, then reclaim the disk.
|
||||||
|
* Uncommitted work waits for a decision.
|
||||||
|
|
||||||
|
## Commit and Push
|
||||||
|
|
||||||
|
* Commit often, in small ordered commits.
|
||||||
|
* Each commit says why, what changed, and how you proved it.
|
||||||
|
* Push to the main line at the end.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Command keeporgd is the KeepOrg synchronization server.
|
||||||
|
//
|
||||||
|
// It stores KDBX files as opaque, versioned blobs and arbitrates concurrent
|
||||||
|
// writes. It never sees a master password and cannot read a database.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/server"
|
||||||
|
"cc.tiredsysadmin.keeporg/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is stamped at build time from versions.mk.
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
|
// fileConfig is the on-disk shape of the configuration. It keeps durations as
|
||||||
|
// plain numbers so the file stays easy to edit by hand.
|
||||||
|
type fileConfig struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
DataDir string `json:"data_dir"`
|
||||||
|
ExternalURL string `json:"external_url"`
|
||||||
|
SessionTTLHours int `json:"session_ttl_hours"`
|
||||||
|
MaxDBSizeMB int64 `json:"max_db_size_mb"`
|
||||||
|
TLSCert string `json:"tls_cert"`
|
||||||
|
TLSKey string `json:"tls_key"`
|
||||||
|
OIDC server.OIDCConfig `json:"oidc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fileConfig) toConfig() server.Config {
|
||||||
|
c := server.Config{
|
||||||
|
Addr: f.Addr, DataDir: f.DataDir, ExternalURL: f.ExternalURL,
|
||||||
|
TLSCert: f.TLSCert, TLSKey: f.TLSKey, OIDC: f.OIDC,
|
||||||
|
}
|
||||||
|
if f.SessionTTLHours > 0 {
|
||||||
|
c.SessionTTL = time.Duration(f.SessionTTLHours) * time.Hour
|
||||||
|
}
|
||||||
|
if f.MaxDBSizeMB > 0 {
|
||||||
|
c.MaxDBSize = f.MaxDBSizeMB << 20
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||||
|
lg := log.New(os.Stderr, "keeporgd: ", log.LstdFlags|log.LUTC)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configPath = flag.String("config", "", "path to a JSON configuration file")
|
||||||
|
addr = flag.String("addr", "", "listen address (overrides the config file)")
|
||||||
|
dataDir = flag.String("data", "", "data directory (overrides the config file)")
|
||||||
|
extURL = flag.String("url", "", "externally reachable base URL (overrides the config file)")
|
||||||
|
showVer = flag.Bool("version", false, "print the version and exit")
|
||||||
|
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVer {
|
||||||
|
fmt.Println("keeporgd", Version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *writeCfg != "" {
|
||||||
|
if err := writeExampleConfig(*writeCfg); err != nil {
|
||||||
|
lg.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote", *writeCfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var fc fileConfig
|
||||||
|
if *configPath != "" {
|
||||||
|
b, err := os.ReadFile(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
lg.Fatalf("read config: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &fc); err != nil {
|
||||||
|
lg.Fatalf("parse config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg := fc.toConfig()
|
||||||
|
if *addr != "" {
|
||||||
|
cfg.Addr = *addr
|
||||||
|
}
|
||||||
|
if *dataDir != "" {
|
||||||
|
cfg.DataDir = *dataDir
|
||||||
|
}
|
||||||
|
if *extURL != "" {
|
||||||
|
cfg.ExternalURL = *extURL
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := server.New(cfg, lg)
|
||||||
|
if err != nil {
|
||||||
|
lg.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if err := bootstrap(srv, lg); err != nil {
|
||||||
|
lg.Fatalf("bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
srv.StartHousekeeping(done)
|
||||||
|
|
||||||
|
hs := &http.Server{
|
||||||
|
Addr: srv.Config().Addr,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
|
// No write timeout: the event stream is long lived by design.
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
eff := srv.Config()
|
||||||
|
lg.Printf("version %s listening on %s (external %s, data %s)",
|
||||||
|
Version, eff.Addr, eff.ExternalURL, eff.DataDir)
|
||||||
|
var err error
|
||||||
|
if eff.TLSCert != "" && eff.TLSKey != "" {
|
||||||
|
err = hs.ListenAndServeTLS(eff.TLSCert, eff.TLSKey)
|
||||||
|
} else {
|
||||||
|
err = hs.ListenAndServe()
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
lg.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sig := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sig
|
||||||
|
close(done)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := hs.Shutdown(ctx); err != nil {
|
||||||
|
lg.Printf("shutdown: %v", err)
|
||||||
|
}
|
||||||
|
lg.Print("stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap creates the first administrator and a root group on an empty
|
||||||
|
// server, so a fresh deployment is immediately usable. It is idempotent.
|
||||||
|
func bootstrap(srv *server.Server, lg *log.Logger) error {
|
||||||
|
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
|
||||||
|
generated := false
|
||||||
|
if password == "" {
|
||||||
|
password = secret.Token()[:20]
|
||||||
|
generated = true
|
||||||
|
}
|
||||||
|
var created bool
|
||||||
|
err := srv.Store().Update(func(st *store.State) error {
|
||||||
|
if len(st.Users) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := secret.Hash(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := &store.User{ID: secret.ID(), Username: "admin", PassHash: hash,
|
||||||
|
ServerAdmin: true, CreatedAt: time.Now()}
|
||||||
|
st.Users[u.ID] = u
|
||||||
|
g := &store.Group{ID: secret.ID(), Name: "everyone", CreatedAt: time.Now()}
|
||||||
|
st.Groups[g.ID] = g
|
||||||
|
st.Memberships = append(st.Memberships,
|
||||||
|
store.Membership{UserID: u.ID, GroupID: g.ID, Role: store.RoleAdmin})
|
||||||
|
created = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || !created {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if generated {
|
||||||
|
lg.Printf("created the first administrator: username %q password %q "+
|
||||||
|
"(shown once, change it or enrol TOTP now)", "admin", password)
|
||||||
|
} else {
|
||||||
|
lg.Print(`created the first administrator: username "admin" with the password from KEEPORG_ADMIN_PASSWORD`)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExampleConfig(path string) error {
|
||||||
|
ex := fileConfig{
|
||||||
|
Addr: ":8765",
|
||||||
|
DataDir: "/var/lib/keeporg",
|
||||||
|
ExternalURL: "https://keeporg.example.com",
|
||||||
|
SessionTTLHours: 24 * 90,
|
||||||
|
MaxDBSizeMB: 32,
|
||||||
|
OIDC: server.OIDCConfig{
|
||||||
|
Issuer: "https://id.example.com/realms/main", ClientID: "keeporg",
|
||||||
|
ClientSecret: "change-me", Scopes: "openid profile email", AutoCreate: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(ex, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, append(b, '\n'), 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package blob stores the KDBX payloads. Each database keeps a numbered
|
||||||
|
// version history, which makes a rejected push recoverable and a bad save
|
||||||
|
// reversible.
|
||||||
|
package blob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeepVersions is how many historical versions of each database are retained.
|
||||||
|
const KeepVersions = 20
|
||||||
|
|
||||||
|
type Store struct{ dir string }
|
||||||
|
|
||||||
|
// Open prepares dir as a blob store.
|
||||||
|
func Open(dir string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{dir: dir}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256 is the lowercase hex digest used everywhere as a content fingerprint.
|
||||||
|
func SHA256(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put writes one version of a database. Writing the same version twice with
|
||||||
|
// the same bytes is a no-op, so a retried request is harmless.
|
||||||
|
func (s *Store) Put(dbID string, version int64, data []byte) error {
|
||||||
|
dir := filepath.Join(s.dir, dbID)
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := s.versionPath(dbID, version)
|
||||||
|
if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reads one version of a database.
|
||||||
|
func (s *Store) Get(dbID string, version int64) ([]byte, error) {
|
||||||
|
return os.ReadFile(s.versionPath(dbID, version))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versions lists the stored versions of a database, oldest first.
|
||||||
|
func (s *Store) Versions(dbID string) []int64 {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(s.dir, dbID))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []int64{}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".kdbx") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune removes all but the newest KeepVersions versions.
|
||||||
|
func (s *Store) Prune(dbID string) error {
|
||||||
|
vs := s.Versions(dbID)
|
||||||
|
if len(vs) <= KeepVersions {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, v := range vs[:len(vs)-KeepVersions] {
|
||||||
|
if err := os.Remove(s.versionPath(dbID, v)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget deletes every version of a database.
|
||||||
|
func (s *Store) Forget(dbID string) error {
|
||||||
|
return os.RemoveAll(filepath.Join(s.dir, dbID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) versionPath(dbID string, version int64) string {
|
||||||
|
return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version))
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package blob
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
|
||||||
|
s, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Put("db", 1, []byte("v1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Put("db", 1, []byte("v1")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.Put("db", 2, []byte("v2")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := s.Get("db", 1)
|
||||||
|
if err != nil || string(got) != "v1" {
|
||||||
|
t.Fatalf("get v1 = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 {
|
||||||
|
t.Fatalf("versions = %v", vs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneKeepsNewest(t *testing.T) {
|
||||||
|
s, _ := Open(t.TempDir())
|
||||||
|
for i := int64(1); i <= KeepVersions+5; i++ {
|
||||||
|
if err := s.Put("db", i, []byte{byte(i)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.Prune("db"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vs := s.Versions("db")
|
||||||
|
if len(vs) != KeepVersions || vs[0] != 6 {
|
||||||
|
t.Fatalf("after prune = %v", vs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package secret holds the small amount of cryptography the server needs:
|
||||||
|
// random identifiers, bearer tokens and password verifiers.
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/pbkdf2"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// iterations follows the OWASP recommendation for PBKDF2-HMAC-SHA256.
|
||||||
|
const iterations = 600000
|
||||||
|
|
||||||
|
// ID returns a short random identifier, safe in URLs and filenames.
|
||||||
|
func ID() string { return randHex(8) }
|
||||||
|
|
||||||
|
// Token returns a 256-bit bearer token.
|
||||||
|
func Token() string { return randHex(32) }
|
||||||
|
|
||||||
|
// UserCode returns a short, human-transcribable code for device login.
|
||||||
|
// The alphabet omits characters that are easy to confuse when read aloud.
|
||||||
|
func UserCode() string {
|
||||||
|
const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
|
||||||
|
b := randBytes(8)
|
||||||
|
out := make([]byte, 0, 9)
|
||||||
|
for i, v := range b {
|
||||||
|
if i == 4 {
|
||||||
|
out = append(out, '-')
|
||||||
|
}
|
||||||
|
out = append(out, alphabet[int(v)%len(alphabet)])
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash returns a verifier string for a password.
|
||||||
|
func Hash(password string) (string, error) {
|
||||||
|
if password == "" {
|
||||||
|
return "", errors.New("secret: empty password")
|
||||||
|
}
|
||||||
|
salt := randBytes(16)
|
||||||
|
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, 32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iterations,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(dk)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify reports whether password matches a verifier produced by Hash.
|
||||||
|
// An unparsable or empty verifier never matches.
|
||||||
|
func Verify(verifier, password string) bool {
|
||||||
|
parts := strings.Split(verifier, "$")
|
||||||
|
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
iter, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil || iter < 1000 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares two secrets without leaking their contents through timing.
|
||||||
|
func Equal(a, b string) bool {
|
||||||
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func randBytes(n int) []byte {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic("secret: " + err.Error()) // crypto/rand never fails on supported platforms
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func randHex(n int) string { return hex.EncodeToString(randBytes(n)) }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package secret
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashVerifyRoundTrip(t *testing.T) {
|
||||||
|
h, err := Hash("correct horse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !Verify(h, "correct horse") {
|
||||||
|
t.Fatal("correct password rejected")
|
||||||
|
}
|
||||||
|
if Verify(h, "wrong horse") {
|
||||||
|
t.Fatal("wrong password accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsJunkVerifier(t *testing.T) {
|
||||||
|
for _, v := range []string{"", "x", "pbkdf2-sha256$1$a$b", "md5$1$a$b"} {
|
||||||
|
if Verify(v, "anything") {
|
||||||
|
t.Fatalf("verifier %q accepted", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCodeShape(t *testing.T) {
|
||||||
|
c := UserCode()
|
||||||
|
if len(c) != 9 || c[4] != '-' {
|
||||||
|
t.Fatalf("user code %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// maxDepth bounds every tree walk so a corrupted parent cycle cannot hang the
|
||||||
|
// server. Group creation rejects cycles, so this is a backstop only.
|
||||||
|
const maxDepth = 64
|
||||||
|
|
||||||
|
// Ancestors returns groupID and every parent above it, nearest first.
|
||||||
|
func (s *State) Ancestors(groupID string) []string {
|
||||||
|
out := []string{}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for id := groupID; id != "" && !seen[id] && len(out) < maxDepth; {
|
||||||
|
g, ok := s.Groups[id]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
out = append(out, id)
|
||||||
|
id = g.ParentID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descendants returns groupID and every group below it.
|
||||||
|
func (s *State) Descendants(groupID string) []string {
|
||||||
|
if _, ok := s.Groups[groupID]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := []string{groupID}
|
||||||
|
for i := 0; i < len(out); i++ {
|
||||||
|
for id, g := range s.Groups {
|
||||||
|
if g.ParentID == out[i] && !contains(out, id) {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAncestor reports whether anc is at or above desc in the tree.
|
||||||
|
func (s *State) IsAncestor(anc, desc string) bool {
|
||||||
|
return contains(s.Ancestors(desc), anc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveGroups is the set of groups whose grants reach the user: every
|
||||||
|
// group the user belongs to, plus every group above those. A grant on
|
||||||
|
// "Company" therefore reaches a member of "Company/Engineering/Backend".
|
||||||
|
func (s *State) EffectiveGroups(userID string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, m := range s.Memberships {
|
||||||
|
if m.UserID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, id := range s.Ancestors(m.GroupID) {
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGroupAdmin reports whether the user administers groupID, either directly
|
||||||
|
// or by administering a group above it. Server admins administer everything.
|
||||||
|
func (s *State) IsGroupAdmin(userID, groupID string) bool {
|
||||||
|
if u := s.Users[userID]; u != nil && u.ServerAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
anc := s.Ancestors(groupID)
|
||||||
|
for _, m := range s.Memberships {
|
||||||
|
if m.UserID == userID && m.Role == RoleAdmin && contains(anc, m.GroupID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatabasePerm is the strongest permission the user holds on a database.
|
||||||
|
func (s *State) DatabasePerm(userID, dbID string) Perm {
|
||||||
|
db, ok := s.Databases[dbID]
|
||||||
|
if !ok {
|
||||||
|
return PermNone
|
||||||
|
}
|
||||||
|
if s.IsGroupAdmin(userID, db.OwnerGroupID) {
|
||||||
|
return PermAdmin
|
||||||
|
}
|
||||||
|
reach := s.EffectiveGroups(userID)
|
||||||
|
best := PermNone
|
||||||
|
for _, g := range s.Grants {
|
||||||
|
if g.DatabaseID != dbID || !reach[g.GroupID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.Perm.Rank() > best.Rank() {
|
||||||
|
best = g.Perm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment pairs a database with the caller's permission on it.
|
||||||
|
type Assignment struct {
|
||||||
|
DB *Database
|
||||||
|
Perm Perm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignments lists every database the user may see, strongest permission
|
||||||
|
// first resolved, sorted by name for a stable client view.
|
||||||
|
func (s *State) Assignments(userID string) []Assignment {
|
||||||
|
out := []Assignment{}
|
||||||
|
for id, db := range s.Databases {
|
||||||
|
if p := s.DatabasePerm(userID, id); p != PermNone {
|
||||||
|
out = append(out, Assignment{DB: db, Perm: p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].DB.Name != out[j].DB.Name {
|
||||||
|
return out[i].DB.Name < out[j].DB.Name
|
||||||
|
}
|
||||||
|
return out[i].DB.ID < out[j].DB.ID
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watchers lists the user IDs that must be told when a database changes.
|
||||||
|
func (s *State) Watchers(dbID string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for id := range s.Users {
|
||||||
|
if s.DatabasePerm(id, dbID) != PermNone {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(ss []string, s string) bool {
|
||||||
|
for _, v := range ss {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// tree: company > engineering > backend, company > sales
|
||||||
|
func aclFixture() *State {
|
||||||
|
s := newState()
|
||||||
|
s.Groups["company"] = &Group{ID: "company", Name: "Company"}
|
||||||
|
s.Groups["eng"] = &Group{ID: "eng", Name: "Engineering", ParentID: "company"}
|
||||||
|
s.Groups["backend"] = &Group{ID: "backend", Name: "Backend", ParentID: "eng"}
|
||||||
|
s.Groups["sales"] = &Group{ID: "sales", Name: "Sales", ParentID: "company"}
|
||||||
|
s.Users["alice"] = &User{ID: "alice", Username: "alice"}
|
||||||
|
s.Users["bob"] = &User{ID: "bob", Username: "bob"}
|
||||||
|
s.Users["root"] = &User{ID: "root", Username: "root", ServerAdmin: true}
|
||||||
|
s.Memberships = []Membership{
|
||||||
|
{UserID: "alice", GroupID: "backend", Role: RoleMember},
|
||||||
|
{UserID: "bob", GroupID: "sales", Role: RoleAdmin},
|
||||||
|
}
|
||||||
|
s.Databases["shared"] = &Database{ID: "shared", Name: "Shared", OwnerGroupID: "company"}
|
||||||
|
s.Databases["salesdb"] = &Database{ID: "salesdb", Name: "Sales", OwnerGroupID: "sales"}
|
||||||
|
s.Grants = []Grant{
|
||||||
|
{GroupID: "company", DatabaseID: "shared", Perm: PermRead},
|
||||||
|
{GroupID: "eng", DatabaseID: "shared", Perm: PermWrite},
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantOnAncestorReachesDescendantMember(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
if got := s.DatabasePerm("alice", "shared"); got != PermWrite {
|
||||||
|
t.Fatalf("alice on shared = %q, want write (read from company, write from eng)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantDoesNotLeakAcrossSiblings(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
if got := s.DatabasePerm("alice", "salesdb"); got != PermNone {
|
||||||
|
t.Fatalf("alice on salesdb = %q, want none", got)
|
||||||
|
}
|
||||||
|
if got := s.DatabasePerm("bob", "shared"); got != PermRead {
|
||||||
|
t.Fatalf("bob on shared = %q, want read", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnerGroupAdminGetsAdmin(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
if got := s.DatabasePerm("bob", "salesdb"); got != PermAdmin {
|
||||||
|
t.Fatalf("bob on own group db = %q, want admin", got)
|
||||||
|
}
|
||||||
|
if !s.IsGroupAdmin("root", "backend") {
|
||||||
|
t.Fatal("server admin should administer any group")
|
||||||
|
}
|
||||||
|
if s.IsGroupAdmin("bob", "eng") {
|
||||||
|
t.Fatal("sales admin must not administer engineering")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOfParentAdministersChild(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
s.Memberships = append(s.Memberships, Membership{UserID: "alice", GroupID: "company", Role: RoleAdmin})
|
||||||
|
if !s.IsGroupAdmin("alice", "backend") {
|
||||||
|
t.Fatal("company admin should administer backend")
|
||||||
|
}
|
||||||
|
if got := s.DatabasePerm("alice", "salesdb"); got != PermAdmin {
|
||||||
|
t.Fatalf("company admin on salesdb = %q, want admin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignmentsAndWatchers(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
got := s.Assignments("alice")
|
||||||
|
if len(got) != 1 || got[0].DB.ID != "shared" || got[0].Perm != PermWrite {
|
||||||
|
t.Fatalf("alice assignments = %+v", got)
|
||||||
|
}
|
||||||
|
w := s.Watchers("shared")
|
||||||
|
if len(w) != 3 {
|
||||||
|
t.Fatalf("watchers of shared = %v, want alice, bob and root", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTreeWalksSurviveCycle(t *testing.T) {
|
||||||
|
s := aclFixture()
|
||||||
|
s.Groups["company"].ParentID = "backend" // corrupt
|
||||||
|
if len(s.Ancestors("backend")) != 3 {
|
||||||
|
t.Fatalf("cycle walk = %v", s.Ancestors("backend"))
|
||||||
|
}
|
||||||
|
if len(s.Descendants("company")) == 0 {
|
||||||
|
t.Fatal("descendants must terminate on a cycle")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Package store holds the server's authoritative metadata: users, nested
|
||||||
|
// groups, databases and the grants that connect them.
|
||||||
|
//
|
||||||
|
// Glossary:
|
||||||
|
//
|
||||||
|
// User an account, authenticated by password+TOTP or by OIDC.
|
||||||
|
// Group a node in a single tree of groups. Grants hang off groups.
|
||||||
|
// Membership a user's place in one group, as member or admin.
|
||||||
|
// Database one KDBX file tracked by the server. Owned by one group.
|
||||||
|
// Grant a (group, database, permission) triple.
|
||||||
|
// Invite a single- or multi-use registration token.
|
||||||
|
// Session a bearer token held by a client device.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Perm is a permission on a database, ordered read < write < admin.
|
||||||
|
type Perm string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermNone Perm = ""
|
||||||
|
PermRead Perm = "read"
|
||||||
|
PermWrite Perm = "write"
|
||||||
|
PermAdmin Perm = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
|
||||||
|
|
||||||
|
// Rank orders permissions so the strongest of several grants can be picked.
|
||||||
|
func (p Perm) Rank() int { return permRank[p] }
|
||||||
|
|
||||||
|
// AtLeast reports whether p is as strong as want.
|
||||||
|
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
|
||||||
|
|
||||||
|
// Valid reports whether p is a permission that may be stored.
|
||||||
|
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
|
||||||
|
|
||||||
|
// Role is a user's role inside a group.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleMember Role = "member"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid reports whether r is a role that may be stored.
|
||||||
|
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
|
||||||
|
|
||||||
|
// Strategy decides what happens when a client pushes a database version that
|
||||||
|
// is no longer current.
|
||||||
|
type Strategy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// StrategyServerWins rejects the stale push; the client takes the server
|
||||||
|
// copy and keeps its own as a conflict file. This is the default.
|
||||||
|
StrategyServerWins Strategy = "server-wins"
|
||||||
|
// StrategyClientWins lets the client overwrite the server copy.
|
||||||
|
StrategyClientWins Strategy = "client-wins"
|
||||||
|
// StrategyManual rejects the stale push and leaves both copies in place
|
||||||
|
// for a human to reconcile.
|
||||||
|
StrategyManual Strategy = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid reports whether s is a strategy that may be stored.
|
||||||
|
func (s Strategy) Valid() bool {
|
||||||
|
return s == StrategyServerWins || s == StrategyClientWins || s == StrategyManual
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PassHash string `json:"pass_hash,omitempty"`
|
||||||
|
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||||
|
OIDCSubject string `json:"oidc_subject,omitempty"`
|
||||||
|
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||||
|
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||||
|
ServerAdmin bool `json:"server_admin"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentID string `json:"parent_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Membership struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OwnerGroupID string `json:"owner_group_id"`
|
||||||
|
MergeStrategy 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grant struct {
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
DatabaseID string `json:"database_id"`
|
||||||
|
Perm Perm `json:"perm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
GroupID string `json:"group_id,omitempty"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
MaxUses int `json:"max_uses"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usable reports whether the invite may still be redeemed at time now.
|
||||||
|
func (i *Invite) Usable(now time.Time) bool {
|
||||||
|
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
|
||||||
|
// Android: the client shows a short user code, the browser approves it, the
|
||||||
|
// client polls until a session token appears.
|
||||||
|
type DeviceAuth struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
SessionToken string `json:"session_token,omitempty"`
|
||||||
|
OIDCState string `json:"oidc_state,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the whole metadata set, serialized as one JSON document.
|
||||||
|
type State struct {
|
||||||
|
Users map[string]*User `json:"users"`
|
||||||
|
Groups map[string]*Group `json:"groups"`
|
||||||
|
Databases map[string]*Database `json:"databases"`
|
||||||
|
Invites map[string]*Invite `json:"invites"`
|
||||||
|
Sessions map[string]*Session `json:"sessions"`
|
||||||
|
Devices map[string]*DeviceAuth `json:"devices"`
|
||||||
|
Memberships []Membership `json:"memberships"`
|
||||||
|
Grants []Grant `json:"grants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newState() *State {
|
||||||
|
return &State{
|
||||||
|
Users: map[string]*User{},
|
||||||
|
Groups: map[string]*Group{},
|
||||||
|
Databases: map[string]*Database{},
|
||||||
|
Invites: map[string]*Invite{},
|
||||||
|
Sessions: map[string]*Session{},
|
||||||
|
Devices: map[string]*DeviceAuth{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) init() {
|
||||||
|
if s.Users == nil {
|
||||||
|
s.Users = map[string]*User{}
|
||||||
|
}
|
||||||
|
if s.Groups == nil {
|
||||||
|
s.Groups = map[string]*Group{}
|
||||||
|
}
|
||||||
|
if s.Databases == nil {
|
||||||
|
s.Databases = map[string]*Database{}
|
||||||
|
}
|
||||||
|
if s.Invites == nil {
|
||||||
|
s.Invites = map[string]*Invite{}
|
||||||
|
}
|
||||||
|
if s.Sessions == nil {
|
||||||
|
s.Sessions = map[string]*Session{}
|
||||||
|
}
|
||||||
|
if s.Devices == nil {
|
||||||
|
s.Devices = map[string]*DeviceAuth{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserByName finds a user by exact username.
|
||||||
|
func (s *State) UserByName(name string) *User {
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if u.Username == name {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
|
||||||
|
func (s *State) UserByOIDC(issuer, subject string) *User {
|
||||||
|
for _, u := range s.Users {
|
||||||
|
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceByUserCode finds a pending device login by the code shown to the user.
|
||||||
|
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
|
||||||
|
for _, d := range s.Devices {
|
||||||
|
if d.UserCode == code {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is the single writer of the metadata document. Every mutation runs on
|
||||||
|
// a private copy of the state and is swapped in only once it has reached the
|
||||||
|
// disk, so a reader never observes a half-applied update and a crash never
|
||||||
|
// leaves a partial file.
|
||||||
|
type Store struct {
|
||||||
|
path string
|
||||||
|
mu sync.RWMutex
|
||||||
|
s *State
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open loads the state document at path, creating an empty one if absent.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st := &Store{path: path, s: newState()}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
return st, st.persist(st.s)
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := newState()
|
||||||
|
if err := json.Unmarshal(b, s); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
s.init()
|
||||||
|
st.s = s
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read runs fn against a consistent view of the state. fn must not retain or
|
||||||
|
// mutate anything it is given.
|
||||||
|
func (st *Store) Read(fn func(*State)) {
|
||||||
|
st.mu.RLock()
|
||||||
|
defer st.mu.RUnlock()
|
||||||
|
fn(st.s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies fn to a copy of the state and commits it. If fn or the write
|
||||||
|
// fails, the stored state is left untouched.
|
||||||
|
func (st *Store) Update(fn func(*State) error) error {
|
||||||
|
st.mu.Lock()
|
||||||
|
defer st.mu.Unlock()
|
||||||
|
next, err := clone(st.s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := fn(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := st.persist(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.s = next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GC drops expired sessions, device logins and invites.
|
||||||
|
func (st *Store) GC(now time.Time) error {
|
||||||
|
return st.Update(func(s *State) error {
|
||||||
|
for k, v := range s.Sessions {
|
||||||
|
if now.After(v.ExpiresAt) {
|
||||||
|
delete(s.Sessions, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range s.Devices {
|
||||||
|
if now.After(v.ExpiresAt) {
|
||||||
|
delete(s.Devices, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range s.Invites {
|
||||||
|
if !v.Usable(now) {
|
||||||
|
delete(s.Invites, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) persist(s *State) error {
|
||||||
|
b, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeFileAtomic(st.path, b, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clone(s *State) (*State, error) {
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := newState()
|
||||||
|
if err := json.Unmarshal(b, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.init()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic replaces path with data, surviving a crash mid-write.
|
||||||
|
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
st, err := Open(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Update(func(s *State) error {
|
||||||
|
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := Open(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
again.Read(func(s *State) { name = s.Users["u1"].Username })
|
||||||
|
if name != "alice" {
|
||||||
|
t.Fatalf("reopened username = %q", name)
|
||||||
|
}
|
||||||
|
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("state file mode = %v", fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||||
|
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
boom := errors.New("boom")
|
||||||
|
if err := st.Update(func(s *State) error {
|
||||||
|
s.Users["u1"] = &User{ID: "u1"}
|
||||||
|
return boom
|
||||||
|
}); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
st.Read(func(s *State) { n = len(s.Users) })
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("users after failed update = %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGCDropsExpired(t *testing.T) {
|
||||||
|
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
now := time.Now()
|
||||||
|
_ = st.Update(func(s *State) error {
|
||||||
|
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
|
||||||
|
s.Sessions["new"] = &Session{Token: "new", ExpiresAt: now.Add(time.Hour)}
|
||||||
|
s.Invites["spent"] = &Invite{Token: "spent", ExpiresAt: now.Add(time.Hour), MaxUses: 1, Uses: 1}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err := st.GC(now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st.Read(func(s *State) {
|
||||||
|
if len(s.Sessions) != 1 || s.Sessions["new"] == nil {
|
||||||
|
t.Fatalf("sessions after GC = %v", s.Sessions)
|
||||||
|
}
|
||||||
|
if len(s.Invites) != 0 {
|
||||||
|
t.Fatalf("invites after GC = %v", s.Invites)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Package totp implements RFC 6238 time-based one-time passwords, the
|
||||||
|
// built-in second factor for password logins.
|
||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
digits = 6
|
||||||
|
step = 30 * time.Second
|
||||||
|
// skew is how many steps either side of now are accepted, covering
|
||||||
|
// clock drift and a user typing slowly.
|
||||||
|
skew = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSecret returns a fresh base32 secret with no padding, as authenticator
|
||||||
|
// apps expect.
|
||||||
|
func NewSecret() string {
|
||||||
|
b := make([]byte, 20)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic("totp: " + err.Error())
|
||||||
|
}
|
||||||
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URI is the otpauth:// URL an authenticator app can scan.
|
||||||
|
func URI(issuer, account, secret string) string {
|
||||||
|
label := url.PathEscape(issuer + ":" + account)
|
||||||
|
q := url.Values{"secret": {secret}, "issuer": {issuer}, "algorithm": {"SHA1"},
|
||||||
|
"digits": {fmt.Sprint(digits)}, "period": {"30"}}
|
||||||
|
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is the password for secret at time t.
|
||||||
|
func Code(secret string, t time.Time) (string, error) {
|
||||||
|
key, err := decode(secret)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return code(key, uint64(t.Unix())/uint64(step.Seconds())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reports whether given is a valid password for secret at time t,
|
||||||
|
// allowing one step of drift either way.
|
||||||
|
func Check(secret, given string, t time.Time) bool {
|
||||||
|
key, err := decode(secret)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
given = strings.TrimSpace(given)
|
||||||
|
if len(given) != digits {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctr := uint64(t.Unix()) / uint64(step.Seconds())
|
||||||
|
for d := -skew; d <= skew; d++ {
|
||||||
|
if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(secret string) ([]byte, error) {
|
||||||
|
s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", ""))
|
||||||
|
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func code(key []byte, counter uint64) string {
|
||||||
|
var buf [8]byte
|
||||||
|
binary.BigEndian.PutUint64(buf[:], counter)
|
||||||
|
mac := hmac.New(sha1.New, key)
|
||||||
|
mac.Write(buf[:])
|
||||||
|
sum := mac.Sum(nil)
|
||||||
|
off := sum[len(sum)-1] & 0x0f
|
||||||
|
trunc := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
|
||||||
|
mod := uint32(1)
|
||||||
|
for i := 0; i < digits; i++ {
|
||||||
|
mod *= 10
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%0*d", digits, trunc%mod)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package totp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RFC 6238 appendix B, SHA1 test vectors for the ASCII key "12345678901234567890".
|
||||||
|
func TestRFC6238Vectors(t *testing.T) {
|
||||||
|
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of the RFC key
|
||||||
|
for _, tc := range []struct {
|
||||||
|
unix int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{59, "287082"},
|
||||||
|
{1111111109, "081804"},
|
||||||
|
{1111111111, "050471"},
|
||||||
|
{1234567890, "005924"},
|
||||||
|
{2000000000, "279037"},
|
||||||
|
} {
|
||||||
|
got, err := Code(secret, time.Unix(tc.unix, 0))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Code at %d = %s, want %s", tc.unix, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAcceptsDriftAndRejectsJunk(t *testing.T) {
|
||||||
|
s := NewSecret()
|
||||||
|
now := time.Now()
|
||||||
|
c, _ := Code(s, now)
|
||||||
|
if !Check(s, c, now.Add(29*time.Second)) {
|
||||||
|
t.Fatal("code within drift window rejected")
|
||||||
|
}
|
||||||
|
if Check(s, c, now.Add(5*time.Minute)) {
|
||||||
|
t.Fatal("stale code accepted")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", "12345", "abcdef", "1234567"} {
|
||||||
|
if Check(s, bad, now) {
|
||||||
|
t.Fatalf("junk code %q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Single source of truth for all platform versions (semver).
|
||||||
|
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
||||||
|
VERSION_SERVER := 0.1.0
|
||||||
|
VERSION_LINUX := 0.1.0
|
||||||
|
VERSION_MACOS := 0.1.0
|
||||||
|
VERSION_WINDOWS := 0.1.0
|
||||||
|
VERSION_BSD := 0.1.0
|
||||||
|
VERSION_ANDROID := 0.1.0
|
||||||
|
# Monotonic Android versionCode, never decreases.
|
||||||
|
ANDROID_VERSION_CODE := 1
|
||||||
|
|
||||||
|
# Reverse-DNS application identifiers.
|
||||||
|
APPID_ANDROID := cc.tiredsysadmin.android.keeporg
|
||||||
|
APPID_MACOS := cc.tiredsysadmin.macos.keeporg
|
||||||
|
APPID_LINUX := cc.tiredsysadmin.linux.keeporg
|
||||||
|
APPID_WINDOWS := cc.tiredsysadmin.windows.keeporg
|
||||||
|
APPID_BSD := cc.tiredsysadmin.bsd.keeporg
|
||||||
|
APPID_SERVER := cc.tiredsysadmin.linux.keeporgd
|
||||||
Reference in New Issue
Block a user