From fc63f9c44a2fe48c30be9e93211f78b618dd25b4 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Thu, 28 May 2026 08:35:50 -0400 Subject: [PATCH] server auth --- apps/server/go.mod | 13 +- apps/server/go.sum | 32 +- apps/server/internal/app/app.go | 7 + apps/server/internal/app/watcher.go | 186 +++++- apps/server/internal/app/watcher_test.go | 84 +++ apps/server/internal/auth/crypto.go | 171 ++++++ apps/server/internal/auth/repository.go | 534 +++++++++++++++++ apps/server/internal/auth/service.go | 546 ++++++++++++++++++ apps/server/internal/auth/service_test.go | 181 ++++++ apps/server/internal/auth/types.go | 117 ++++ apps/server/internal/config/config.go | 9 + .../database/migrations/000009_auth.up.sql | 90 +++ apps/server/internal/docs/service_test.go | 65 +++ .../internal/httpserver/auth_context.go | 23 + .../internal/httpserver/auth_handlers.go | 477 +++++++++++++++ apps/server/internal/httpserver/handlers.go | 67 +++ .../internal/httpserver/handlers_test.go | 58 ++ apps/server/internal/httpserver/middleware.go | 85 +++ apps/server/internal/httpserver/rate_limit.go | 49 ++ .../internal/httpserver/rate_limit_test.go | 19 + apps/server/internal/httpserver/server.go | 30 +- .../server/internal/httpserver/static/auth.js | 262 +++++++++ .../internal/httpserver/static/cache.js | 24 + .../httpserver/static/cairnquire logo@2x.webp | Bin 425150 -> 137310 bytes .../internal/httpserver/static/favicon.png | Bin 5328 -> 4552 bytes .../internal/httpserver/static/render.js | 6 +- .../internal/httpserver/static/site.css | 340 ++++++++++- apps/server/internal/httpserver/static/sw.js | 2 +- .../server/internal/httpserver/static/sync.js | 120 +++- .../internal/httpserver/sync_handlers.go | 37 +- .../httpserver/templates/account.gohtml | 72 +++ .../internal/httpserver/templates/base.gohtml | 20 +- .../httpserver/templates/device_verify.gohtml | 21 + .../httpserver/templates/login.gohtml | 76 +++ apps/server/internal/markdown/renderer.go | 1 + .../server/internal/markdown/renderer_test.go | 13 + apps/server/internal/sync/service_test.go | 114 ++++ 37 files changed, 3877 insertions(+), 74 deletions(-) create mode 100644 apps/server/internal/app/watcher_test.go create mode 100644 apps/server/internal/auth/crypto.go create mode 100644 apps/server/internal/auth/repository.go create mode 100644 apps/server/internal/auth/service.go create mode 100644 apps/server/internal/auth/service_test.go create mode 100644 apps/server/internal/auth/types.go create mode 100644 apps/server/internal/database/migrations/000009_auth.up.sql create mode 100644 apps/server/internal/httpserver/auth_context.go create mode 100644 apps/server/internal/httpserver/auth_handlers.go create mode 100644 apps/server/internal/httpserver/rate_limit.go create mode 100644 apps/server/internal/httpserver/rate_limit_test.go create mode 100644 apps/server/internal/httpserver/static/auth.js create mode 100644 apps/server/internal/httpserver/templates/account.gohtml create mode 100644 apps/server/internal/httpserver/templates/device_verify.gohtml create mode 100644 apps/server/internal/httpserver/templates/login.gohtml diff --git a/apps/server/go.mod b/apps/server/go.mod index ff29ad4..9b11de1 100644 --- a/apps/server/go.mod +++ b/apps/server/go.mod @@ -3,16 +3,25 @@ module github.com/tim/cairnquire/apps/server go 1.24.2 require ( + github.com/fsnotify/fsnotify v1.9.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/go-webauthn/webauthn v0.15.0 github.com/gorilla/websocket v1.5.3 github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 github.com/yuin/goldmark v1.8.2 + golang.org/x/crypto v0.43.0 ) require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-webauthn/x v0.1.26 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/go-tpm v0.9.6 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.37.0 // indirect ) diff --git a/apps/server/go.sum b/apps/server/go.sum index 78b8254..a460e7a 100644 --- a/apps/server/go.sum +++ b/apps/server/go.sum @@ -1,26 +1,54 @@ github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY= +github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= +github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= +github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA= +github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc= github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index a41d145..1be1ab8 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -9,6 +9,7 @@ import ( "os" "time" + "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/docs" @@ -61,6 +62,11 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err syncRepo := sync.NewRepository(db.SQL()) syncService := sync.NewService(syncRepo, service, contentStore, cfg.Content.SourceDir, logger) + authRepo := auth.NewRepository(db.SQL()) + authService, err := auth.NewService(authRepo, cfg.Auth.PublicOrigin) + if err != nil { + return nil, fmt.Errorf("build auth service: %w", err) + } handler, err := httpserver.New(httpserver.Dependencies{ Config: cfg, @@ -71,6 +77,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err Hub: hub, SyncService: syncService, SyncRepo: syncRepo, + Auth: authService, }) if err != nil { return nil, fmt.Errorf("build http handler: %w", err) diff --git a/apps/server/internal/app/watcher.go b/apps/server/internal/app/watcher.go index 829520a..9b47fe4 100644 --- a/apps/server/internal/app/watcher.go +++ b/apps/server/internal/app/watcher.go @@ -7,15 +7,19 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/fsnotify/fsnotify" ) +const fileWatcherDebounce = 500 * time.Millisecond + type FileWatcher struct { watcher *fsnotify.Watcher rootDir string onChange func(path string) logger *slog.Logger + debounce time.Duration } func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) { @@ -25,9 +29,10 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) { } fw := &FileWatcher{ - watcher: watcher, - rootDir: rootDir, - logger: logger, + watcher: watcher, + rootDir: rootDir, + logger: logger, + debounce: fileWatcherDebounce, } if err := fw.addWatches(); err != nil { @@ -39,19 +44,150 @@ func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) { } func (fw *FileWatcher) addWatches() error { - return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error { + return filepath.WalkDir(fw.rootDir, func(path string, entry os.DirEntry, err error) error { if err != nil { return nil } - if info.IsDir() { - if err := fw.watcher.Add(path); err != nil { - fw.logger.Warn("watch directory", "path", path, "error", err) - } + if !entry.IsDir() { + return nil + } + if path != fw.rootDir && fw.shouldIgnorePath(path) { + return filepath.SkipDir + } + if err := fw.watcher.Add(path); err != nil { + fw.logger.Warn("watch directory", "path", path, "error", err) } return nil }) } +func (fw *FileWatcher) addWatchIfDirectory(path string) { + if fw.shouldIgnorePath(path) { + return + } + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return + } + if err := fw.watcher.Add(path); err != nil { + fw.logger.Warn("watch new directory", "path", path, "error", err) + } +} + +func (fw *FileWatcher) flushPending(pending map[string]struct{}) { + if len(pending) == 0 || fw.onChange == nil { + return + } + + var changedPath string + for path := range pending { + changedPath = path + break + } + clear(pending) + fw.onChange(changedPath) +} + +func (fw *FileWatcher) scheduleSync(path string, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) { + pending[path] = struct{}{} + + if *timer == nil { + *timer = time.NewTimer(fw.debounce) + *timerC = (*timer).C + return + } + + if !(*timer).Stop() { + select { + case <-(*timer).C: + default: + } + } + (*timer).Reset(fw.debounce) +} + +func (fw *FileWatcher) stopTimer(timer *time.Timer) { + if timer == nil { + return + } + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } +} + +func (fw *FileWatcher) isMarkdownPath(path string) bool { + return strings.EqualFold(filepath.Ext(path), ".md") +} + +func (fw *FileWatcher) shouldIgnorePath(path string) bool { + rel, err := filepath.Rel(fw.rootDir, path) + if err != nil { + return true + } + if rel == "." { + return false + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return true + } + + for _, part := range strings.Split(rel, string(filepath.Separator)) { + if part == "" { + continue + } + if strings.HasPrefix(part, ".") { + return true + } + if isTempFileName(part) { + return true + } + } + return false +} + +func isTempFileName(name string) bool { + if strings.HasSuffix(name, "~") { + return true + } + if strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") { + return true + } + + switch strings.ToLower(filepath.Ext(name)) { + case ".swp", ".swo", ".tmp", ".temp", ".bak", ".orig", ".part", ".crdownload": + return true + } + + switch name { + case ".DS_Store", "Thumbs.db": + return true + default: + return false + } +} + +func (fw *FileWatcher) isProcessableEvent(event fsnotify.Event) bool { + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { + return false + } + if fw.shouldIgnorePath(event.Name) { + return false + } + return fw.isMarkdownPath(event.Name) +} + +func (fw *FileWatcher) handleEvent(event fsnotify.Event, pending map[string]struct{}, timer **time.Timer, timerC *<-chan time.Time) { + if event.Op&(fsnotify.Create|fsnotify.Rename) != 0 { + fw.addWatchIfDirectory(event.Name) + } + if fw.isProcessableEvent(event) { + fw.scheduleSync(event.Name, pending, timer, timerC) + } +} + func (fw *FileWatcher) SetOnChange(fn func(path string)) { fw.onChange = fn } @@ -59,24 +195,24 @@ func (fw *FileWatcher) SetOnChange(fn func(path string)) { func (fw *FileWatcher) Run(ctx context.Context) { defer fw.watcher.Close() + pending := make(map[string]struct{}) + var timer *time.Timer + var timerC <-chan time.Time + defer fw.stopTimer(timer) + for { select { case <-ctx.Done(): return + case <-timerC: + fw.flushPending(pending) + timer = nil + timerC = nil case event, ok := <-fw.watcher.Events: if !ok { return } - if fw.shouldProcess(event) { - if event.Op&fsnotify.Create == fsnotify.Create { - if info, err := os.Stat(event.Name); err == nil && info.IsDir() { - fw.watcher.Add(event.Name) - } - } - if fw.onChange != nil { - fw.onChange(event.Name) - } - } + fw.handleEvent(event, pending, &timer, &timerC) case err, ok := <-fw.watcher.Errors: if !ok { return @@ -85,17 +221,3 @@ func (fw *FileWatcher) Run(ctx context.Context) { } } } - -func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool { - if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { - return false - } - name := filepath.Base(event.Name) - if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") { - return false - } - if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 { - return true - } - return false -} diff --git a/apps/server/internal/app/watcher_test.go b/apps/server/internal/app/watcher_test.go new file mode 100644 index 0000000..d004500 --- /dev/null +++ b/apps/server/internal/app/watcher_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +func TestFileWatcherFiltersProcessableEvents(t *testing.T) { + t.Parallel() + + root := t.TempDir() + watcher := &FileWatcher{rootDir: root, logger: slog.Default()} + + tests := []struct { + name string + path string + op fsnotify.Op + want bool + }{ + {name: "markdown write", path: "guide.md", op: fsnotify.Write, want: true}, + {name: "markdown create", path: "nested/guide.md", op: fsnotify.Create, want: true}, + {name: "case insensitive markdown", path: "guide.MD", op: fsnotify.Write, want: true}, + {name: "markdown delete", path: "guide.md", op: fsnotify.Remove, want: true}, + {name: "non markdown write", path: "logo.webp", op: fsnotify.Write, want: false}, + {name: "non markdown delete", path: "logo.webp", op: fsnotify.Remove, want: false}, + {name: "hidden markdown file", path: ".guide.md", op: fsnotify.Write, want: false}, + {name: "hidden directory", path: ".cache/guide.md", op: fsnotify.Write, want: false}, + {name: "backup file", path: "guide.md~", op: fsnotify.Write, want: false}, + {name: "vim swap", path: "guide.md.swp", op: fsnotify.Write, want: false}, + {name: "emacs lock file", path: "#guide.md#", op: fsnotify.Write, want: false}, + {name: "chmod only", path: "guide.md", op: fsnotify.Chmod, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := fsnotify.Event{Name: filepath.Join(root, tt.path), Op: tt.op} + if got := watcher.isProcessableEvent(event); got != tt.want { + t.Fatalf("isProcessableEvent() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileWatcherDebouncesPendingSyncs(t *testing.T) { + t.Parallel() + + root := t.TempDir() + watcher := &FileWatcher{ + rootDir: root, + logger: slog.Default(), + debounce: 10 * time.Millisecond, + } + + var changed []string + watcher.SetOnChange(func(path string) { + changed = append(changed, path) + }) + + pending := make(map[string]struct{}) + var timer *time.Timer + var timerC <-chan time.Time + defer watcher.stopTimer(timer) + + watcher.scheduleSync(filepath.Join(root, "one.md"), pending, &timer, &timerC) + watcher.scheduleSync(filepath.Join(root, "two.md"), pending, &timer, &timerC) + + select { + case <-timerC: + watcher.flushPending(pending) + case <-time.After(250 * time.Millisecond): + t.Fatal("debounce timer did not fire") + } + + if len(changed) != 1 { + t.Fatalf("onChange calls = %d, want 1", len(changed)) + } + if len(pending) != 0 { + t.Fatalf("pending changes = %d, want 0", len(pending)) + } +} diff --git a/apps/server/internal/auth/crypto.go b/apps/server/internal/auth/crypto.go new file mode 100644 index 0000000..977cda4 --- /dev/null +++ b/apps/server/internal/auth/crypto.go @@ -0,0 +1,171 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + passwordTime uint32 = 3 + passwordMemory uint32 = 64 * 1024 + passwordThreads uint8 = 4 + passwordKeyLen uint32 = 32 + passwordSaltLen = 16 +) + +func randomBytes(length int) ([]byte, error) { + buf := make([]byte, length) + if _, err := rand.Read(buf); err != nil { + return nil, err + } + return buf, nil +} + +func randomToken(length int) (string, error) { + buf, err := randomBytes(length) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func randomHex(length int) (string, error) { + buf, err := randomBytes(length) + if err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func hashSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +func constantTimeEqualHex(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func hashPassword(password string) (string, error) { + salt, err := randomBytes(passwordSaltLen) + if err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, passwordTime, passwordMemory, passwordThreads, passwordKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + passwordMemory, + passwordTime, + passwordThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash), + ), nil +} + +func verifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, fmt.Errorf("invalid password hash") + } + + var memory uint64 + var timeCost uint64 + var threads uint64 + for _, param := range strings.Split(parts[3], ",") { + keyValue := strings.SplitN(param, "=", 2) + if len(keyValue) != 2 { + return false, fmt.Errorf("invalid password hash parameters") + } + value, err := strconv.ParseUint(keyValue[1], 10, 32) + if err != nil { + return false, fmt.Errorf("parse password hash parameter: %w", err) + } + switch keyValue[0] { + case "m": + memory = value + case "t": + timeCost = value + case "p": + threads = value + } + } + if memory == 0 || timeCost == 0 || threads == 0 { + return false, fmt.Errorf("missing password hash parameters") + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("decode password salt: %w", err) + } + expected, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("decode password hash: %w", err) + } + + actual := argon2.IDKey([]byte(password), salt, uint32(timeCost), uint32(memory), uint8(threads), uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1, nil +} + +func normalizeScopes(scopes []Scope) []Scope { + if len(scopes) == 0 { + return []Scope{ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite} + } + seen := make(map[Scope]struct{}, len(scopes)) + var normalized []Scope + for _, scope := range scopes { + switch scope { + case ScopeDocsRead, ScopeDocsWrite, ScopeSyncRead, ScopeSyncWrite, ScopeAdmin: + if _, ok := seen[scope]; !ok { + seen[scope] = struct{}{} + normalized = append(normalized, scope) + } + } + } + if len(normalized) == 0 { + return []Scope{ScopeDocsRead} + } + return normalized +} + +func scopesToString(scopes []Scope) string { + normalized := normalizeScopes(scopes) + parts := make([]string, 0, len(normalized)) + for _, scope := range normalized { + parts = append(parts, string(scope)) + } + return strings.Join(parts, ",") +} + +func scopesFromString(raw string) []Scope { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + scopes := make([]Scope, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + scopes = append(scopes, Scope(trimmed)) + } + } + return normalizeScopes(scopes) +} + +func hasScope(scopes []Scope, required Scope) bool { + for _, scope := range scopes { + if scope == ScopeAdmin || scope == required { + return true + } + } + return false +} diff --git a/apps/server/internal/auth/repository.go b/apps/server/internal/auth/repository.go new file mode 100644 index 0000000..4300e8a --- /dev/null +++ b/apps/server/internal/auth/repository.go @@ -0,0 +1,534 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) UpsertUser(ctx context.Context, user User) (User, error) { + now := time.Now().UTC() + if user.ID == "" { + user.ID = "user:" + user.Email + } + if user.Role == "" { + user.Role = RoleViewer + } + if user.DisplayName == "" { + user.DisplayName = user.Email + } + + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO users (id, email, display_name, password_hash, role, created_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(email) DO UPDATE SET + display_name = excluded.display_name, + password_hash = COALESCE(excluded.password_hash, users.password_hash), + role = excluded.role, + last_seen_at = excluded.last_seen_at + `, user.ID, user.Email, user.DisplayName, nullString(user.PasswordHash), string(user.Role), now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { + return User{}, fmt.Errorf("upsert user: %w", err) + } + + return r.GetUserByEmail(ctx, user.Email) +} + +func (r *Repository) GetUserByEmail(ctx context.Context, email string) (User, error) { + var user User + var created, lastSeen, passwordHash, role string + err := r.db.QueryRowContext(ctx, ` + SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') + FROM users + WHERE email = ? + `, email).Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash, &role, &created, &lastSeen) + if err != nil { + return User{}, err + } + user.PasswordHash = passwordHash + user.Role = Role(role) + user.CreatedAt, err = time.Parse(time.RFC3339, created) + if err != nil { + return User{}, fmt.Errorf("parse user created_at: %w", err) + } + if lastSeen != "" { + user.LastSeenAt, err = time.Parse(time.RFC3339, lastSeen) + if err != nil { + return User{}, fmt.Errorf("parse user last_seen_at: %w", err) + } + } + user.Credentials, err = r.ListCredentials(ctx, user.ID) + if err != nil { + return User{}, err + } + return user, nil +} + +func (r *Repository) GetUserByID(ctx context.Context, userID string) (User, error) { + var email string + if err := r.db.QueryRowContext(ctx, `SELECT email FROM users WHERE id = ?`, userID).Scan(&email); err != nil { + return User{}, err + } + return r.GetUserByEmail(ctx, email) +} + +func (r *Repository) CountUsers(ctx context.Context) (int, error) { + var count int + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (r *Repository) CountAdmins(ctx context.Context) (int, error) { + var count int + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func (r *Repository) ListUsers(ctx context.Context) ([]User, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, email, COALESCE(display_name, ''), COALESCE(password_hash, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') + FROM users + ORDER BY created_at DESC + `) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var users []User + for rows.Next() { + var user User + var created, lastSeen, role string + if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &role, &created, &lastSeen); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + user.Role = Role(role) + user.CreatedAt, _ = time.Parse(time.RFC3339, created) + if lastSeen != "" { + user.LastSeenAt, _ = time.Parse(time.RFC3339, lastSeen) + } + users = append(users, user) + } + return users, rows.Err() +} + +func (r *Repository) UpdatePasswordHash(ctx context.Context, userID, passwordHash string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE users + SET password_hash = ?, last_seen_at = ? + WHERE id = ? + `, passwordHash, time.Now().UTC().Format(time.RFC3339), userID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (r *Repository) UpdateUserRole(ctx context.Context, userID string, role Role) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE users + SET role = ?, last_seen_at = ? + WHERE id = ? + `, string(role), time.Now().UTC().Format(time.RFC3339), userID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (r *Repository) DeleteUser(ctx context.Context, userID string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (r *Repository) SaveCredential(ctx context.Context, userID string, credential webauthn.Credential) error { + now := time.Now().UTC().Format(time.RFC3339) + payload, err := json.Marshal(credential) + if err != nil { + return fmt.Errorf("marshal credential: %w", err) + } + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO webauthn_credentials (id, user_id, credential_id, credential_json, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(credential_id) DO UPDATE SET + credential_json = excluded.credential_json, + last_used_at = excluded.last_used_at + `, "cred:"+hashSecret(string(credential.ID))[:24], userID, credential.ID, string(payload), now, now); err != nil { + return fmt.Errorf("save credential: %w", err) + } + return nil +} + +func (r *Repository) ListCredentials(ctx context.Context, userID string) ([]webauthn.Credential, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT credential_json + FROM webauthn_credentials + WHERE user_id = ? + ORDER BY created_at ASC + `, userID) + if err != nil { + return nil, fmt.Errorf("list credentials: %w", err) + } + defer rows.Close() + + var credentials []webauthn.Credential + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, fmt.Errorf("scan credential: %w", err) + } + var credential webauthn.Credential + if err := json.Unmarshal([]byte(raw), &credential); err != nil { + return nil, fmt.Errorf("unmarshal credential: %w", err) + } + credentials = append(credentials, credential) + } + return credentials, rows.Err() +} + +func (r *Repository) SaveChallenge(ctx context.Context, userID, challengeType string, session webauthn.SessionData, ttl time.Duration) (string, error) { + idPart, err := randomToken(18) + if err != nil { + return "", err + } + payload, err := json.Marshal(session) + if err != nil { + return "", fmt.Errorf("marshal challenge: %w", err) + } + now := time.Now().UTC() + id := "chal:" + idPart + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO auth_challenges (id, user_id, type, session_json, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `, id, userID, challengeType, string(payload), now.Format(time.RFC3339), now.Add(ttl).Format(time.RFC3339)); err != nil { + return "", fmt.Errorf("save challenge: %w", err) + } + return id, nil +} + +func (r *Repository) ConsumeChallenge(ctx context.Context, id, challengeType string) (webauthn.SessionData, string, error) { + var raw, userID, expires, used string + err := r.db.QueryRowContext(ctx, ` + SELECT session_json, user_id, expires_at, COALESCE(used_at, '') + FROM auth_challenges + WHERE id = ? AND type = ? + `, id, challengeType).Scan(&raw, &userID, &expires, &used) + if err != nil { + return webauthn.SessionData{}, "", err + } + if used != "" { + return webauthn.SessionData{}, "", fmt.Errorf("challenge already used") + } + expiresAt, err := time.Parse(time.RFC3339, expires) + if err != nil { + return webauthn.SessionData{}, "", fmt.Errorf("parse challenge expiry: %w", err) + } + if time.Now().UTC().After(expiresAt) { + return webauthn.SessionData{}, "", fmt.Errorf("challenge expired") + } + var session webauthn.SessionData + if err := json.Unmarshal([]byte(raw), &session); err != nil { + return webauthn.SessionData{}, "", fmt.Errorf("unmarshal challenge: %w", err) + } + if _, err := r.db.ExecContext(ctx, `UPDATE auth_challenges SET used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id); err != nil { + return webauthn.SessionData{}, "", fmt.Errorf("consume challenge: %w", err) + } + return session, userID, nil +} + +func (r *Repository) CreateSession(ctx context.Context, userID, tokenHash, ip, userAgent string, ttl time.Duration) (Session, error) { + idPart, err := randomToken(18) + if err != nil { + return Session{}, err + } + now := time.Now().UTC() + session := Session{ + ID: "sess:" + idPart, + UserID: userID, + TokenHash: tokenHash, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, last_seen_at, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, session.ID, session.UserID, session.TokenHash, session.CreatedAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), ip, userAgent); err != nil { + return Session{}, fmt.Errorf("create session: %w", err) + } + return session, nil +} + +func (r *Repository) ValidateSession(ctx context.Context, tokenHash string) (Session, User, error) { + var session Session + var created, expires, revoked string + err := r.db.QueryRowContext(ctx, ` + SELECT id, user_id, token_hash, created_at, expires_at, COALESCE(revoked_at, '') + FROM sessions + WHERE token_hash = ? + `, tokenHash).Scan(&session.ID, &session.UserID, &session.TokenHash, &created, &expires, &revoked) + if err != nil { + return Session{}, User{}, err + } + session.CreatedAt, err = time.Parse(time.RFC3339, created) + if err != nil { + return Session{}, User{}, err + } + session.ExpiresAt, err = time.Parse(time.RFC3339, expires) + if err != nil { + return Session{}, User{}, err + } + if revoked != "" { + return Session{}, User{}, fmt.Errorf("session revoked") + } + if time.Now().UTC().After(session.ExpiresAt) { + return Session{}, User{}, fmt.Errorf("session expired") + } + user, err := r.GetUserByID(ctx, session.UserID) + if err != nil { + return Session{}, User{}, err + } + _, _ = r.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), session.ID) + return session, user, nil +} + +func (r *Repository) RevokeSession(ctx context.Context, sessionID string) error { + _, err := r.db.ExecContext(ctx, `UPDATE sessions SET revoked_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), sessionID) + return err +} + +func (r *Repository) CreateAPIKey(ctx context.Context, key APIKey) error { + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO api_keys (id, user_id, name, key_hash, scopes, created_at, expires_at, last_used_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, key.ID, key.UserID, key.Name, key.KeyHash, scopesToString(key.Scopes), key.CreatedAt.Format(time.RFC3339), timePtrString(key.ExpiresAt), timePtrString(key.LastUsedAt), timePtrString(key.RevokedAt)); err != nil { + return fmt.Errorf("create api key: %w", err) + } + return nil +} + +func (r *Repository) ValidateAPIKey(ctx context.Context, keyID, keyHash string) (APIKey, User, error) { + var key APIKey + var scopesRaw, created, expires, lastUsed, revoked string + err := r.db.QueryRowContext(ctx, ` + SELECT id, user_id, name, key_hash, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '') + FROM api_keys + WHERE id = ? + `, keyID).Scan(&key.ID, &key.UserID, &key.Name, &key.KeyHash, &scopesRaw, &created, &expires, &lastUsed, &revoked) + if err != nil { + return APIKey{}, User{}, err + } + if !constantTimeEqualHex(key.KeyHash, keyHash) { + return APIKey{}, User{}, sql.ErrNoRows + } + key.Scopes = scopesFromString(scopesRaw) + key.CreatedAt, err = time.Parse(time.RFC3339, created) + if err != nil { + return APIKey{}, User{}, err + } + if expires != "" { + parsed, err := time.Parse(time.RFC3339, expires) + if err != nil { + return APIKey{}, User{}, err + } + if time.Now().UTC().After(parsed) { + return APIKey{}, User{}, fmt.Errorf("api key expired") + } + key.ExpiresAt = &parsed + } + if revoked != "" { + return APIKey{}, User{}, fmt.Errorf("api key revoked") + } + if lastUsed != "" { + parsed, err := time.Parse(time.RFC3339, lastUsed) + if err == nil { + key.LastUsedAt = &parsed + } + } + user, err := r.GetUserByID(ctx, key.UserID) + if err != nil { + return APIKey{}, User{}, err + } + _, _ = r.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), key.ID) + return key, user, nil +} + +func (r *Repository) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, user_id, name, scopes, created_at, COALESCE(expires_at, ''), COALESCE(last_used_at, ''), COALESCE(revoked_at, '') + FROM api_keys + WHERE user_id = ? + ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []APIKey + for rows.Next() { + var key APIKey + var scopesRaw, created, expires, lastUsed, revoked string + if err := rows.Scan(&key.ID, &key.UserID, &key.Name, &scopesRaw, &created, &expires, &lastUsed, &revoked); err != nil { + return nil, err + } + key.Scopes = scopesFromString(scopesRaw) + key.CreatedAt, _ = time.Parse(time.RFC3339, created) + if expires != "" { + parsed, _ := time.Parse(time.RFC3339, expires) + key.ExpiresAt = &parsed + } + if lastUsed != "" { + parsed, _ := time.Parse(time.RFC3339, lastUsed) + key.LastUsedAt = &parsed + } + if revoked != "" { + parsed, _ := time.Parse(time.RFC3339, revoked) + key.RevokedAt = &parsed + } + keys = append(keys, key) + } + return keys, rows.Err() +} + +func (r *Repository) RevokeAPIKey(ctx context.Context, userID, keyID string) error { + result, err := r.db.ExecContext(ctx, `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?`, time.Now().UTC().Format(time.RFC3339), keyID, userID) + if err != nil { + return err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return sql.ErrNoRows + } + return nil +} + +func (r *Repository) CreateDeviceCode(ctx context.Context, device DeviceCode) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO device_codes (id, device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, device.ID, device.DeviceCodeHash, device.UserCodeHash, device.UserCodeDisplay, device.Name, scopesToString(device.Scopes), device.Status, device.CreatedAt.Format(time.RFC3339), device.ExpiresAt.Format(time.RFC3339)) + return err +} + +func (r *Repository) GetDeviceCodeByUserCode(ctx context.Context, userCodeHash string) (DeviceCode, error) { + return r.getDeviceCode(ctx, `user_code_hash = ?`, userCodeHash) +} + +func (r *Repository) GetDeviceCodeByDeviceCode(ctx context.Context, deviceCodeHash string) (DeviceCode, error) { + return r.getDeviceCode(ctx, `device_code_hash = ?`, deviceCodeHash) +} + +func (r *Repository) getDeviceCode(ctx context.Context, predicate string, value string) (DeviceCode, error) { + var device DeviceCode + var scopesRaw, created, expires, approved, lastPolled, userID string + row := r.db.QueryRowContext(ctx, ` + SELECT id, COALESCE(user_id, ''), device_code_hash, user_code_hash, user_code_display, name, scopes, status, created_at, expires_at, COALESCE(approved_at, ''), COALESCE(last_polled_at, '') + FROM device_codes + WHERE `+predicate, value) + if err := row.Scan(&device.ID, &userID, &device.DeviceCodeHash, &device.UserCodeHash, &device.UserCodeDisplay, &device.Name, &scopesRaw, &device.Status, &created, &expires, &approved, &lastPolled); err != nil { + return DeviceCode{}, err + } + device.UserID = userID + device.Scopes = scopesFromString(scopesRaw) + device.CreatedAt, _ = time.Parse(time.RFC3339, created) + device.ExpiresAt, _ = time.Parse(time.RFC3339, expires) + if approved != "" { + parsed, _ := time.Parse(time.RFC3339, approved) + device.ApprovedAt = &parsed + } + if lastPolled != "" { + parsed, _ := time.Parse(time.RFC3339, lastPolled) + device.LastPolledAt = &parsed + } + if time.Now().UTC().After(device.ExpiresAt) && device.Status == "pending" { + device.Status = "expired" + _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired' WHERE id = ?`, device.ID) + } + return device, nil +} + +func (r *Repository) ApproveDeviceCode(ctx context.Context, userCodeHash, userID string) (DeviceCode, error) { + now := time.Now().UTC().Format(time.RFC3339) + result, err := r.db.ExecContext(ctx, ` + UPDATE device_codes + SET status = 'approved', user_id = ?, approved_at = ? + WHERE user_code_hash = ? AND status = 'pending' + `, userID, now, userCodeHash) + if err != nil { + return DeviceCode{}, err + } + if rows, _ := result.RowsAffected(); rows == 0 { + return DeviceCode{}, sql.ErrNoRows + } + return r.GetDeviceCodeByUserCode(ctx, userCodeHash) +} + +func (r *Repository) TouchDevicePoll(ctx context.Context, id string) { + _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) +} + +func (r *Repository) FinishDeviceCode(ctx context.Context, id string) { + _, _ = r.db.ExecContext(ctx, `UPDATE device_codes SET status = 'expired', last_polled_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id) +} + +func (r *Repository) Audit(ctx context.Context, actorUserID, eventType, resourceType, resourceID, ip, userAgent string, metadata map[string]any) { + idPart, err := randomToken(18) + if err != nil { + return + } + if metadata == nil { + metadata = map[string]any{} + } + payload, err := json.Marshal(metadata) + if err != nil { + payload = []byte("{}") + } + _, _ = r.db.ExecContext(ctx, ` + INSERT INTO audit_log (id, actor_user_id, event_type, resource_type, resource_id, ip_address, user_agent, created_at, metadata_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "audit:"+idPart, nullString(actorUserID), eventType, nullString(resourceType), nullString(resourceID), ip, userAgent, time.Now().UTC().Format(time.RFC3339), string(payload)) +} + +func nullString(value string) any { + if value == "" { + return nil + } + return value +} + +func timePtrString(value *time.Time) any { + if value == nil { + return nil + } + return value.UTC().Format(time.RFC3339) +} + +func isNoRows(err error) bool { + return errors.Is(err, sql.ErrNoRows) +} diff --git a/apps/server/internal/auth/service.go b/apps/server/internal/auth/service.go new file mode 100644 index 0000000..0b5c705 --- /dev/null +++ b/apps/server/internal/auth/service.go @@ -0,0 +1,546 @@ +package auth + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + SessionCookieName = "cairnquire_session" + sessionTTL = 24 * time.Hour + challengeTTL = 5 * time.Minute + deviceCodeTTL = 15 * time.Minute + devicePollSeconds = 5 +) + +type Service struct { + repo *Repository + webauthn *webauthn.WebAuthn + publicOrigin string +} + +func NewService(repo *Repository, publicOrigin string) (*Service, error) { + if publicOrigin == "" { + publicOrigin = "http://localhost:8080" + } + parsed, err := url.Parse(publicOrigin) + if err != nil { + return nil, fmt.Errorf("parse public origin: %w", err) + } + rpID := parsed.Hostname() + if rpID == "" { + return nil, fmt.Errorf("public origin must include host") + } + web, err := webauthn.New(&webauthn.Config{ + RPID: rpID, + RPDisplayName: "Cairnquire", + RPOrigins: []string{publicOrigin}, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementPreferred, + UserVerification: protocol.VerificationPreferred, + }, + AttestationPreference: protocol.PreferNoAttestation, + }) + if err != nil { + return nil, err + } + return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil +} + +func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) { + email = normalizeEmail(email) + if email == "" { + return User{}, fmt.Errorf("email is required") + } + if len(password) < 12 { + return User{}, fmt.Errorf("password must be at least 12 characters") + } + if _, err := s.repo.GetUserByEmail(ctx, email); err == nil { + return User{}, fmt.Errorf("user already exists") + } else if err != nil && !isNoRows(err) { + return User{}, err + } + passwordHash, err := hashPassword(password) + if err != nil { + return User{}, err + } + userCount, err := s.repo.CountUsers(ctx) + if err != nil { + return User{}, err + } + normalizedRole := RoleViewer + if userCount == 0 { + normalizedRole = RoleAdmin + } else if strings.EqualFold(role, string(RoleViewer)) { + normalizedRole = RoleViewer + } + return s.repo.UpsertUser(ctx, User{ + Email: email, + DisplayName: strings.TrimSpace(displayName), + PasswordHash: passwordHash, + Role: normalizedRole, + }) +} + +func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) { + user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) + if err != nil { + if isNoRows(err) { + _, _ = verifyPassword(password, mustDummyPasswordHash()) + } + return Principal{}, "", fmt.Errorf("invalid credentials") + } + if user.PasswordHash == "" { + return Principal{}, "", fmt.Errorf("invalid credentials") + } + ok, err := verifyPassword(password, user.PasswordHash) + if err != nil || !ok { + return Principal{}, "", fmt.Errorf("invalid credentials") + } + principal, token, err := s.createSession(ctx, user, ip, userAgent) + if err == nil { + s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil) + } + return principal, token, err +} + +func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) { + user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) + if err != nil { + if !isNoRows(err) { + return nil, "", err + } + userCount, err := s.repo.CountUsers(ctx) + if err != nil { + return nil, "", err + } + normalizedRole := RoleViewer + if userCount == 0 { + normalizedRole = RoleAdmin + } + user, err = s.repo.UpsertUser(ctx, User{ + Email: normalizeEmail(email), + DisplayName: strings.TrimSpace(displayName), + Role: normalizedRole, + }) + if err != nil { + return nil, "", err + } + } else { + return nil, "", fmt.Errorf("user already exists") + } + creation, session, err := s.webauthn.BeginRegistration(user) + if err != nil { + return nil, "", err + } + challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL) + if err != nil { + return nil, "", err + } + return creation, challengeID, nil +} + +func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) { + session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration") + if err != nil { + return User{}, err + } + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return User{}, err + } + credential, err := s.webauthn.FinishRegistration(user, session, r) + if err != nil { + return User{}, err + } + if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil { + return User{}, err + } + s.repo.Audit(ctx, user.ID, "auth.passkey.register", "user", user.ID, clientIP(r), r.UserAgent(), nil) + return s.repo.GetUserByID(ctx, user.ID) +} + +func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) { + user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) + if err != nil { + return nil, "", err + } + assertion, session, err := s.webauthn.BeginLogin(user) + if err != nil { + return nil, "", err + } + challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL) + if err != nil { + return nil, "", err + } + return assertion, challengeID, nil +} + +func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) { + session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login") + if err != nil { + return Principal{}, "", err + } + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return Principal{}, "", err + } + credential, err := s.webauthn.FinishLogin(user, session, r) + if err != nil { + return Principal{}, "", err + } + if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil { + return Principal{}, "", err + } + principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent()) + if err == nil { + s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil) + } + return principal, token, err +} + +func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) { + session, user, err := s.repo.ValidateSession(ctx, hashSecret(token)) + if err != nil { + return Principal{}, err + } + return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil +} + +func (s *Service) RevokeSession(ctx context.Context, sessionID string) error { + return s.repo.RevokeSession(ctx, sessionID) +} + +func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + if len(newPassword) < 12 { + return fmt.Errorf("password must be at least 12 characters") + } + user, err := s.repo.GetUserByID(ctx, principal.UserID) + if err != nil { + return err + } + if user.PasswordHash != "" { + ok, err := verifyPassword(currentPassword, user.PasswordHash) + if err != nil || !ok { + return fmt.Errorf("invalid credentials") + } + } + passwordHash, err := hashPassword(newPassword) + if err != nil { + return err + } + if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil { + return err + } + s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil) + return nil +} + +func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + user, err := s.repo.GetUserByID(ctx, principal.UserID) + if err != nil { + return err + } + if user.Role == RoleAdmin { + admins, err := s.repo.CountAdmins(ctx) + if err != nil { + return err + } + if admins <= 1 { + return fmt.Errorf("cannot delete the last admin account") + } + } + if user.PasswordHash != "" { + ok, err := verifyPassword(currentPassword, user.PasswordHash) + if err != nil || !ok { + return fmt.Errorf("invalid credentials") + } + } + s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil) + return s.repo.DeleteUser(ctx, user.ID) +} + +func (s *Service) ListUsers(ctx context.Context) ([]User, error) { + return s.repo.ListUsers(ctx) +} + +func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) { + if !Allows(actor, ScopeAdmin) { + return User{}, fmt.Errorf("admin role required") + } + normalizedRole := Role(strings.ToLower(strings.TrimSpace(role))) + switch normalizedRole { + case RoleViewer, RoleEditor, RoleAdmin: + default: + return User{}, fmt.Errorf("invalid role") + } + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return User{}, err + } + if user.Role == RoleAdmin && normalizedRole != RoleAdmin { + admins, err := s.repo.CountAdmins(ctx) + if err != nil { + return User{}, err + } + if admins <= 1 { + return User{}, fmt.Errorf("cannot demote the last admin account") + } + } + if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil { + return User{}, err + } + s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole}) + return s.repo.GetUserByID(ctx, user.ID) +} + +func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) { + user, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return CreatedAPIKey{}, err + } + if strings.TrimSpace(name) == "" { + name = "API token" + } + tokenID, err := randomHex(12) + if err != nil { + return CreatedAPIKey{}, err + } + secret, err := randomHex(32) + if err != nil { + return CreatedAPIKey{}, err + } + now := time.Now().UTC() + keyID := "api:" + tokenID + token := "cq_pat_" + tokenID + "_" + secret + record := APIKey{ + ID: keyID, + UserID: user.ID, + Name: strings.TrimSpace(name), + KeyHash: hashSecret(secret), + Scopes: normalizeScopes(scopes), + CreatedAt: now, + ExpiresAt: expiresAt, + } + if err := s.repo.CreateAPIKey(ctx, record); err != nil { + return CreatedAPIKey{}, err + } + return CreatedAPIKey{Record: record, Token: token}, nil +} + +func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) { + keyID, secret, err := parseAPIToken(token) + if err != nil { + return Principal{}, err + } + key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret)) + if err != nil { + return Principal{}, err + } + expiresAt := time.Time{} + if key.ExpiresAt != nil { + expiresAt = *key.ExpiresAt + } + return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil +} + +func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) { + return s.repo.ListAPIKeys(ctx, userID) +} + +func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error { + return s.repo.RevokeAPIKey(ctx, userID, keyID) +} + +func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) { + deviceSecret, err := randomToken(32) + if err != nil { + return DeviceStart{}, err + } + rawUserCode, err := randomToken(5) + if err != nil { + return DeviceStart{}, err + } + userCode := formatUserCode(rawUserCode) + idPart, err := randomToken(12) + if err != nil { + return DeviceStart{}, err + } + now := time.Now().UTC() + device := DeviceCode{ + ID: "dev:" + idPart, + DeviceCodeHash: hashSecret(deviceSecret), + UserCodeHash: hashSecret(normalizeUserCode(userCode)), + UserCodeDisplay: userCode, + Name: strings.TrimSpace(name), + Scopes: normalizeScopes(scopes), + Status: "pending", + CreatedAt: now, + ExpiresAt: now.Add(deviceCodeTTL), + } + if device.Name == "" { + device.Name = "Device" + } + if err := s.repo.CreateDeviceCode(ctx, device); err != nil { + return DeviceStart{}, err + } + verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify" + return DeviceStart{ + DeviceCode: deviceSecret, + UserCode: userCode, + VerificationURI: verifyURI, + VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode), + ExpiresIn: int(deviceCodeTTL.Seconds()), + Interval: devicePollSeconds, + }, nil +} + +func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) { + if principal.UserID == "" { + return DeviceCode{}, fmt.Errorf("authentication required") + } + return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID) +} + +func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) { + device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode)) + if err != nil { + if errorsIsNoRows(err) { + return CreatedAPIKey{}, "invalid_device_code", err + } + return CreatedAPIKey{}, "", err + } + s.repo.TouchDevicePoll(ctx, device.ID) + switch device.Status { + case "pending": + return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows + case "denied", "expired": + return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status) + case "approved": + created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil) + if err == nil { + s.repo.FinishDeviceCode(ctx, device.ID) + } + return created, "", err + default: + return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status") + } +} + +func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) { + token, err := randomToken(32) + if err != nil { + return Principal{}, "", err + } + session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL) + if err != nil { + return Principal{}, "", err + } + return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil +} + +func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal { + return Principal{ + UserID: user.ID, + Email: user.Email, + DisplayName: user.DisplayName, + Role: user.Role, + Scopes: scopes, + Method: method, + SessionID: sessionID, + APIKeyID: apiKeyID, + ExpiresAt: expiresAt, + } +} + +func parseAPIToken(token string) (string, string, error) { + parts := strings.SplitN(token, "_", 4) + if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" { + return "", "", fmt.Errorf("invalid api token") + } + return "api:" + parts[2], parts[3], nil +} + +func normalizeEmail(email string) string { + return strings.TrimSpace(strings.ToLower(email)) +} + +func formatUserCode(raw string) string { + clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw)) + if len(clean) > 8 { + clean = clean[:8] + } + if len(clean) > 4 { + return clean[:4] + "-" + clean[4:] + } + return clean +} + +func normalizeUserCode(code string) string { + return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code))) +} + +func errorsIsNoRows(err error) bool { + return err == sql.ErrNoRows +} + +var dummyPasswordHash string + +func mustDummyPasswordHash() string { + if dummyPasswordHash == "" { + hash, err := hashPassword("not-the-password") + if err != nil { + panic(err) + } + dummyPasswordHash = hash + } + return dummyPasswordHash +} + +func clientIP(r *http.Request) string { + if r == nil { + return "" + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + return strings.TrimSpace(parts[0]) + } + return r.RemoteAddr +} + +func RoleAllows(role Role, required Scope) bool { + switch required { + case ScopeDocsRead, ScopeSyncRead: + return role == RoleViewer || role == RoleEditor || role == RoleAdmin + case ScopeDocsWrite, ScopeSyncWrite: + return role == RoleEditor || role == RoleAdmin + case ScopeAdmin: + return role == RoleAdmin + default: + return false + } +} + +func Allows(principal Principal, required Scope) bool { + if principal.UserID == "" { + return false + } + if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) { + return false + } + return RoleAllows(principal.Role, required) +} diff --git a/apps/server/internal/auth/service_test.go b/apps/server/internal/auth/service_test.go new file mode 100644 index 0000000..9d24d25 --- /dev/null +++ b/apps/server/internal/auth/service_test.go @@ -0,0 +1,181 @@ +package auth + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" + + "github.com/tim/cairnquire/apps/server/internal/database" +) + +func setupAuthTestService(t *testing.T) *Service { + t.Helper() + + db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.ApplyMigrations(context.Background(), db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + service, err := NewService(NewRepository(db), "http://localhost:8080") + if err != nil { + t.Fatalf("new auth service: %v", err) + } + return service +} + +func TestPasswordLoginCreatesValidSession(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user, err := service.RegisterPasswordUser(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", "editor") + if err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + if user.Email != "dev@example.com" { + t.Fatalf("email = %q, want normalized", user.Email) + } + + principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test") + if err != nil { + t.Fatalf("LoginPassword() error = %v", err) + } + if token == "" { + t.Fatal("expected session token") + } + if principal.Role != RoleAdmin { + t.Fatalf("role = %s, want first registered user to bootstrap as admin", principal.Role) + } + + validated, err := service.ValidateSessionToken(ctx, token) + if err != nil { + t.Fatalf("ValidateSessionToken() error = %v", err) + } + if validated.UserID != principal.UserID { + t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID) + } +} + +func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") + if err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + expires := time.Now().UTC().Add(time.Hour) + created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires) + if err != nil { + t.Fatalf("CreateAPIKey() error = %v", err) + } + if !strings.HasPrefix(created.Token, "cq_pat_") { + t.Fatalf("token prefix = %q, want cq_pat_", created.Token) + } + if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) { + t.Fatal("record contains raw token") + } + + principal, err := service.ValidateBearerToken(ctx, created.Token) + if err != nil { + t.Fatalf("ValidateBearerToken() error = %v", err) + } + if principal.APIKeyID != created.Record.ID { + t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID) + } + if !Allows(principal, ScopeDocsRead) { + t.Fatal("expected docs:read to be allowed") + } + if Allows(principal, ScopeAdmin) { + t.Fatal("did not expect admin scope to be allowed") + } +} + +func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") + if err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead}) + if err != nil { + t.Fatalf("StartDeviceFlow() error = %v", err) + } + + if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil { + t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err) + } + + _, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode) + if err != nil { + t.Fatalf("ApproveDeviceFlow() error = %v", err) + } + created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode) + if err != nil { + t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err) + } + if created.Token == "" { + t.Fatal("expected device flow to mint api token") + } + if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil { + t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err) + } +} + +func TestPasswordChangeInvalidatesOldPassword(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") + if err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) + if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil { + t.Fatalf("ChangePassword() error = %v", err) + } + if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil { + t.Fatal("old password still works") + } + if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil { + t.Fatalf("new password login error = %v", err) + } +} + +func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + user, err := service.RegisterPasswordUser(ctx, "admin@example.com", "Admin", "correct horse battery staple", "admin") + if err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) + if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil { + t.Fatal("expected demoting last admin to fail") + } + if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil { + t.Fatal("expected deleting last admin to fail") + } +} + +func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + + if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil { + t.Fatalf("RegisterPasswordUser() error = %v", err) + } + if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil { + t.Fatal("expected duplicate password registration to fail") + } + if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil { + t.Fatal("expected public passkey registration for existing account to fail") + } +} diff --git a/apps/server/internal/auth/types.go b/apps/server/internal/auth/types.go new file mode 100644 index 0000000..9218f2e --- /dev/null +++ b/apps/server/internal/auth/types.go @@ -0,0 +1,117 @@ +package auth + +import ( + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +type Role string + +const ( + RoleViewer Role = "viewer" + RoleEditor Role = "editor" + RoleAdmin Role = "admin" +) + +type Scope string + +const ( + ScopeDocsRead Scope = "docs:read" + ScopeDocsWrite Scope = "docs:write" + ScopeSyncRead Scope = "sync:read" + ScopeSyncWrite Scope = "sync:write" + ScopeAdmin Scope = "admin" +) + +type Principal struct { + UserID string `json:"userId"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role Role `json:"role"` + Scopes []Scope `json:"scopes,omitempty"` + Method string `json:"method"` + SessionID string `json:"sessionId,omitempty"` + APIKeyID string `json:"apiKeyId,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` +} + +type User struct { + ID string + Email string + DisplayName string + Role Role + PasswordHash string + CreatedAt time.Time + LastSeenAt time.Time + Credentials []webauthn.Credential +} + +func (u User) WebAuthnID() []byte { + return []byte(u.ID) +} + +func (u User) WebAuthnName() string { + return u.Email +} + +func (u User) WebAuthnDisplayName() string { + if u.DisplayName != "" { + return u.DisplayName + } + return u.Email +} + +func (u User) WebAuthnCredentials() []webauthn.Credential { + return u.Credentials +} + +type Session struct { + ID string + UserID string + TokenHash string + CreatedAt time.Time + ExpiresAt time.Time + RevokedAt *time.Time +} + +type APIKey struct { + ID string `json:"id"` + UserID string `json:"userId"` + Name string `json:"name"` + KeyHash string `json:"-"` + Scopes []Scope `json:"scopes"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` +} + +type CreatedAPIKey struct { + Record APIKey `json:"record"` + Token string `json:"token"` +} + +type DeviceCode struct { + ID string + UserID string + DeviceCodeHash string + UserCodeHash string + UserCodeDisplay string + Name string + Scopes []Scope + Status string + CreatedAt time.Time + ExpiresAt time.Time + ApprovedAt *time.Time + LastPolledAt *time.Time +} + +type DeviceStart struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + ExpiresIn int `json:"expiresIn"` + Interval int `json:"interval"` +} diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index d142724..e96c370 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { Database DatabaseConfig `json:"database"` Content ContentConfig `json:"content"` Web WebConfig `json:"web"` + Auth AuthConfig `json:"auth"` LogLevel string `json:"logLevel"` } @@ -35,6 +36,10 @@ type WebConfig struct { DevViteURL string `json:"devViteUrl"` } +type AuthConfig struct { + PublicOrigin string `json:"publicOrigin"` +} + func Default() Config { return Config{ Server: ServerConfig{ @@ -51,6 +56,9 @@ func Default() Config { DistDir: filepath.Join("..", "web", "dist"), DevViteURL: os.Getenv("CAIRNQUIRE_DEV_VITE_URL"), }, + Auth: AuthConfig{ + PublicOrigin: "http://localhost:8080", + }, LogLevel: "INFO", } } @@ -72,6 +80,7 @@ func Load() (Config, error) { overrideString(&cfg.Content.StoreDir, "CAIRNQUIRE_CONTENT_STORE_DIR") overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR") overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL") + overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN") overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL") if cfg.Server.Addr == "" { diff --git a/apps/server/internal/database/migrations/000009_auth.up.sql b/apps/server/internal/database/migrations/000009_auth.up.sql new file mode 100644 index 0000000..e6e4d42 --- /dev/null +++ b/apps/server/internal/database/migrations/000009_auth.up.sql @@ -0,0 +1,90 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT, + ip_address TEXT, + user_agent TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + credential_id BLOB NOT NULL UNIQUE, + credential_json TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); + +CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('webauthn_registration', 'webauthn_login')), + session_json TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_auth_challenges_user_type ON auth_challenges(user_id, type); + +CREATE TABLE IF NOT EXISTS device_codes ( + id TEXT PRIMARY KEY, + user_id TEXT, + device_code_hash TEXT NOT NULL UNIQUE, + user_code_hash TEXT NOT NULL UNIQUE, + user_code_display TEXT NOT NULL, + name TEXT NOT NULL, + scopes TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending', 'approved', 'denied', 'expired')), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + last_polled_at TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_device_codes_user_code_hash ON device_codes(user_code_hash); +CREATE INDEX IF NOT EXISTS idx_device_codes_device_code_hash ON device_codes(device_code_hash); + +CREATE TABLE IF NOT EXISTS permissions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')), + resource_id TEXT, + permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')), + granted_by TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + actor_user_id TEXT, + event_type TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at); + +ALTER TABLE api_keys ADD COLUMN revoked_at TEXT; diff --git a/apps/server/internal/docs/service_test.go b/apps/server/internal/docs/service_test.go index 70e52b6..8fc1178 100644 --- a/apps/server/internal/docs/service_test.go +++ b/apps/server/internal/docs/service_test.go @@ -4,11 +4,13 @@ import ( "context" "database/sql" "errors" + "fmt" "log/slog" "os" "path/filepath" "reflect" "testing" + "time" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/markdown" @@ -110,6 +112,69 @@ func TestSaveSourcePageWithBaseHashAcceptsMatchingHash(t *testing.T) { } } +func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + page, err := service.LoadSourcePage(ctx, "hello") + if err != nil { + t.Fatalf("LoadSourcePage() error = %v", err) + } + + serverContent := "# Hello\n\nServer edit" + if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte(serverContent), 0o644); err != nil { + t.Fatalf("write server edit: %v", err) + } + + _, err = service.SaveSourcePageWithBaseHash(ctx, "hello", "# Hello\n\nQueued offline edit", page.Hash) + var conflict *DocumentConflictError + if !errors.As(err, &conflict) { + t.Fatalf("SaveSourcePageWithBaseHash() error = %v, want DocumentConflictError", err) + } + + resolvedContent := conflict.CurrentContent + "\n\nResolved local addition" + updated, err := service.SaveSourcePageWithBaseHash(ctx, "hello", resolvedContent, conflict.CurrentHash) + if err != nil { + t.Fatalf("SaveSourcePageWithBaseHash() latest hash error = %v", err) + } + if updated.Hash == conflict.CurrentHash { + t.Fatal("expected resolved save to produce a new hash") + } + + content, err := os.ReadFile(filepath.Join(sourceDir, "hello.md")) + if err != nil { + t.Fatalf("read hello.md: %v", err) + } + if string(content) != resolvedContent { + t.Fatalf("resolved content = %q, want %q", string(content), resolvedContent) + } +} + +func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + for i := 0; i < 100; i++ { + name := filepath.Join(sourceDir, fmt.Sprintf("note-%03d.md", i)) + content := fmt.Sprintf("# Note %03d\n\nDeterministic body %03d\n", i, i) + if err := os.WriteFile(name, []byte(content), 0o644); err != nil { + t.Fatalf("create %s: %v", name, err) + } + } + + start := time.Now() + changes, err := service.SyncSourceDir(ctx) + if err != nil { + t.Fatalf("SyncSourceDir() error = %v", err) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("SyncSourceDir() took %s, want <= 10s", elapsed) + } + if len(changes) != 101 { + t.Fatalf("changes = %d, want 101", len(changes)) + } +} + func setupDocsTestService(t *testing.T) (*Service, string) { t.Helper() diff --git a/apps/server/internal/httpserver/auth_context.go b/apps/server/internal/httpserver/auth_context.go new file mode 100644 index 0000000..e0d35b4 --- /dev/null +++ b/apps/server/internal/httpserver/auth_context.go @@ -0,0 +1,23 @@ +package httpserver + +import ( + "context" + "net/http" + + "github.com/tim/cairnquire/apps/server/internal/auth" +) + +type authContextKey struct{} + +func withPrincipal(ctx context.Context, principal auth.Principal) context.Context { + return context.WithValue(ctx, authContextKey{}, principal) +} + +func principalFromContext(ctx context.Context) (auth.Principal, bool) { + principal, ok := ctx.Value(authContextKey{}).(auth.Principal) + return principal, ok +} + +func requirePrincipal(r *http.Request) (auth.Principal, bool) { + return principalFromContext(r.Context()) +} diff --git a/apps/server/internal/httpserver/auth_handlers.go b/apps/server/internal/httpserver/auth_handlers.go new file mode 100644 index 0000000..59b6af8 --- /dev/null +++ b/apps/server/internal/httpserver/auth_handlers.go @@ -0,0 +1,477 @@ +package httpserver + +import ( + "encoding/json" + "html/template" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/tim/cairnquire/apps/server/internal/auth" +) + +type passwordRegisterRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role string `json:"role"` +} + +type passwordLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type passkeyBeginRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` +} + +type passkeyFinishRequest struct { + ChallengeID string `json:"challengeId"` +} + +type createTokenRequest struct { + Name string `json:"name"` + Scopes []auth.Scope `json:"scopes"` + ExpiresAt *time.Time `json:"expiresAt"` +} + +type deviceStartRequest struct { + Name string `json:"name"` + Scopes []auth.Scope `json:"scopes"` +} + +type changePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +type deleteAccountRequest struct { + CurrentPassword string `json:"currentPassword"` +} + +type updateUserRoleRequest struct { + Role string `json:"role"` +} + +func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { + if _, ok := requirePrincipal(r); ok { + http.Redirect(w, r, "/account", http.StatusSeeOther) + return + } + nextPath := r.URL.Query().Get("next") + if !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "//") { + nextPath = "/account" + } + s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{ + Title: "Sign in", + WebEnabled: s.webEnabled, + BodyClass: "page-auth", + BodyTemplate: "login_content", + Data: struct { + Next string + }{Next: nextPath}, + }) +} + +func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{ + Title: "Account", + WebEnabled: s.webEnabled, + BodyClass: "page-account", + BodyTemplate: "account_content", + Data: principal, + }) +} + +func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) +} + +func (s *Server) handlePasswordRegister(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req passwordRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + user, err := s.auth.RegisterPasswordUser(r.Context(), req.Email, req.DisplayName, req.Password, req.Role) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSONWithStatus(w, http.StatusCreated, map[string]any{"user": publicUser(user)}) +} + +func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req passwordLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + principal, token, err := s.auth.LoginPassword(r.Context(), req.Email, req.Password, requestIP(r), r.UserAgent()) + if err != nil { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + setSessionCookie(w, r, token, principal.ExpiresAt) + writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if principal.SessionID != "" { + _ = s.auth.RevokeSession(r.Context(), principal.SessionID) + } + clearSessionCookie(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"}) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + var req changePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if err := s.auth.ChangePassword(r.Context(), principal, req.CurrentPassword, req.NewPassword); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"}) +} + +func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + var req deleteAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if err := s.auth.DeleteAccount(r.Context(), principal, req.CurrentPassword); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + clearSessionCookie(w) + writeJSON(w, http.StatusOK, map[string]string{"status": "account_deleted"}) +} + +func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req passkeyBeginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + options, challengeID, err := s.auth.BeginPasskeyRegistration(r.Context(), req.Email, req.DisplayName, req.Role) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) +} + +func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + challengeID := r.URL.Query().Get("challengeId") + if challengeID == "" { + var req passkeyFinishRequest + _ = json.NewDecoder(r.Body).Decode(&req) + challengeID = req.ChallengeID + } + if challengeID == "" { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) + return + } + user, err := s.auth.FinishPasskeyRegistration(r.Context(), challengeID, r) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) +} + +func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req passkeyBeginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + options, challengeID, err := s.auth.BeginPasskeyLogin(r.Context(), req.Email) + if err != nil { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"challengeId": challengeID, "options": options}) +} + +func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + challengeID := r.URL.Query().Get("challengeId") + if challengeID == "" { + var req passkeyFinishRequest + _ = json.NewDecoder(r.Body).Decode(&req) + challengeID = req.ChallengeID + } + if challengeID == "" { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "challengeId is required"}) + return + } + principal, token, err := s.auth.FinishPasskeyLogin(r.Context(), challengeID, r) + if err != nil { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + setSessionCookie(w, r, token, principal.ExpiresAt) + writeJSON(w, http.StatusOK, map[string]any{"principal": principal}) +} + +func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + keys, err := s.auth.ListAPIKeys(r.Context(), principal.UserID) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"tokens": keys}) +} + +func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + var req createTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + created, err := s.auth.CreateAPIKey(r.Context(), principal.UserID, req.Name, req.Scopes, req.ExpiresAt) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSONWithStatus(w, http.StatusCreated, created) +} + +func (s *Server) handleRevokeAPIToken(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + keyID := chi.URLParam(r, "id") + if !strings.HasPrefix(keyID, "api:") { + keyID = "api:" + keyID + } + if err := s.auth.RevokeAPIKey(r.Context(), principal.UserID, keyID); err != nil { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "token not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) +} + +func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req deviceStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + start, err := s.auth.StartDeviceFlow(r.Context(), req.Name, req.Scopes) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, start) +} + +func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + if !requireSessionPrincipal(w, principal) { + return + } + var req struct { + UserCode string `json:"userCode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + device, err := s.auth.ApproveDeviceFlow(r.Context(), principal, req.UserCode) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid or expired code"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"status": "approved", "device": map[string]any{"name": device.Name, "scopes": device.Scopes}}) +} + +func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) { + if !s.allowAuthAttempt(w, r) { + return + } + var req struct { + DeviceCode string `json:"deviceCode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + created, code, err := s.auth.PollDeviceFlow(r.Context(), req.DeviceCode) + if err != nil { + status := http.StatusBadRequest + if code == "authorization_pending" { + status = http.StatusAccepted + } + writeJSONWithStatus(w, status, map[string]string{"error": code}) + return + } + writeJSON(w, http.StatusOK, created) +} + +func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) { + userCode := template.HTMLEscapeString(r.URL.Query().Get("user_code")) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`

Approve Device

Sign in, then approve this code with POST /api/device/approve.

User code: ` + userCode + `

`)) +} + +func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) { + userCode := strings.TrimSpace(r.URL.Query().Get("user_code")) + s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{ + Title: "Approve device", + WebEnabled: s.webEnabled, + BodyClass: "page-auth", + BodyTemplate: "device_verify_content", + Data: struct { + UserCode string + }{UserCode: userCode}, + }) +} + +func (s *Server) handleAdminUpdateUserRole(w http.ResponseWriter, r *http.Request) { + principal, _ := requirePrincipal(r) + userID := chi.URLParam(r, "id") + var req updateUserRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + user, err := s.auth.UpdateUserRole(r.Context(), principal, userID, req.Role) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": publicUser(user)}) +} + +func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.auth.ListUsers(r.Context()) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + public := make([]map[string]any, 0, len(users)) + for _, user := range users { + public = append(public, publicUser(user)) + } + writeJSON(w, http.StatusOK, map[string]any{"users": public}) +} + +func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool { + if s.authLimiter.Allow(authRateLimitKey(r)) { + return true + } + writeJSONWithStatus(w, http.StatusTooManyRequests, map[string]string{"error": "rate limited"}) + return false +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + Path: "/", + Expires: expires, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: isSecureRequest(r), + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func publicUser(user auth.User) map[string]any { + return map[string]any{ + "id": user.ID, + "email": user.Email, + "displayName": user.DisplayName, + "role": user.Role, + "createdAt": user.CreatedAt, + "lastSeenAt": user.LastSeenAt, + } +} + +func requireSessionPrincipal(w http.ResponseWriter, principal auth.Principal) bool { + if principal.Method == "session" { + return true + } + writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "browser session required"}) + return false +} + +func requestIP(r *http.Request) string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + return strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + return r.RemoteAddr +} + +func isSecureRequest(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index 23dc6fd..46aa4e4 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -137,6 +137,10 @@ func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { pagePath := chi.URLParam(r, "*") + if isContentAssetPath(pagePath) { + s.handleContentAsset(w, r, pagePath) + return + } if editPath, ok := trimEditSuffix(pagePath); ok { s.renderDocumentEditor(w, r, editPath) return @@ -144,6 +148,64 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { s.renderDocumentPage(w, r, pagePath) } +func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) { + cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/"))) + if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + + fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath) + sourceRoot, err := filepath.Abs(s.config.Content.SourceDir) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, "resolve content root") + return + } + assetAbs, err := filepath.Abs(fullPath) + if err != nil { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + relative, err := filepath.Rel(sourceRoot, assetAbs) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + + file, err := os.Open(assetAbs) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + s.renderError(w, r, http.StatusInternalServerError, "read asset") + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil || info.IsDir() { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + + buffer := make([]byte, 512) + n, _ := file.Read(buffer) + if _, err := file.Seek(0, io.SeekStart); err != nil { + s.renderError(w, r, http.StatusInternalServerError, "read asset") + return + } + contentType := http.DetectContentType(buffer[:n]) + if !isAllowedContentType(contentType) { + s.renderError(w, r, http.StatusNotFound, "asset not found") + return + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) { page, err := s.documents.LoadSourcePage(r.Context(), pagePath) if err != nil { @@ -469,6 +531,11 @@ func trimEditSuffix(path string) (string, bool) { return strings.TrimSuffix(path, "/edit"), true } +func isContentAssetPath(path string) bool { + extension := strings.ToLower(filepath.Ext(path)) + return extension != "" && extension != ".md" +} + func buildBrowser(records []docs.DocumentRecord, activePath string) browserData { paths := make([]string, 0, len(records)) titleByPath := make(map[string]string, len(records)) diff --git a/apps/server/internal/httpserver/handlers_test.go b/apps/server/internal/httpserver/handlers_test.go index 05ca22a..8293ce8 100644 --- a/apps/server/internal/httpserver/handlers_test.go +++ b/apps/server/internal/httpserver/handlers_test.go @@ -1,9 +1,14 @@ package httpserver import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" "time" + "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/docs" ) @@ -63,3 +68,56 @@ func TestTrimEditSuffix(t *testing.T) { }) } } + +func TestIsContentAssetPath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {path: "logo.webp", want: true}, + {path: "guide/logo.png", want: true}, + {path: "guide/page", want: false}, + {path: "guide/page.md", want: false}, + {path: "guide/page/edit", want: false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := isContentAssetPath(tt.path); got != tt.want { + t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want) + } + }) + } +} + +func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) { + root := t.TempDir() + image := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, + } + if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + server := &Server{ + config: config.Config{ + Content: config.ContentConfig{SourceDir: root}, + }, + } + request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil) + recorder := httptest.NewRecorder() + + server.handleContentAsset(recorder, request, "logo.png") + + response := recorder.Result() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) + } + if got := response.Header.Get("Content-Type"); got != "image/png" { + t.Fatalf("content-type = %q, want image/png", got) + } +} diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index 45d0cbc..a3f3cb5 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -5,9 +5,12 @@ import ( "fmt" "net" "net/http" + "strings" "time" "github.com/go-chi/chi/v5/middleware" + + "github.com/tim/cairnquire/apps/server/internal/auth" ) func (s *Server) timeoutExceptWebSocket(timeout time.Duration) func(http.Handler) http.Handler { @@ -49,6 +52,88 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { }) } +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := s.authenticateRequest(r) + if ok { + r = r.WithContext(withPrincipal(r.Context(), principal)) + } + + required, protected := requiredScopeForPath(r) + if !protected { + next.ServeHTTP(w, r) + return + } + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + if !auth.Allows(principal, required) { + writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) { + if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { + principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))) + if err == nil { + return principal, true + } + s.logger.Warn("invalid bearer token", "error", err) + return auth.Principal{}, false + } + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil || cookie.Value == "" { + return auth.Principal{}, false + } + principal, err := s.auth.ValidateSessionToken(r.Context(), cookie.Value) + if err != nil { + return auth.Principal{}, false + } + return principal, true +} + +func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { + path := r.URL.Path + method := r.Method + switch { + case strings.HasPrefix(path, "/api/auth/"): + if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" { + return auth.ScopeDocsRead, true + } + return "", false + case strings.HasPrefix(path, "/api/device/"): + if path == "/api/device/approve" || path == "/api/device/verify" { + return auth.ScopeDocsRead, true + } + return "", false + case strings.HasPrefix(path, "/api/tokens"): + return auth.ScopeDocsRead, true + case path == "/ws": + return auth.ScopeDocsRead, true + case strings.HasPrefix(path, "/api/admin/"): + return auth.ScopeAdmin, true + case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet: + return auth.ScopeDocsWrite, true + case strings.HasPrefix(path, "/api/uploads"): + return auth.ScopeDocsWrite, true + case strings.HasPrefix(path, "/api/sync/"): + if method == http.MethodPost { + return auth.ScopeSyncWrite, true + } + return auth.ScopeSyncRead, true + case strings.HasPrefix(path, "/api/content/"): + return auth.ScopeSyncRead, true + case strings.HasSuffix(path, "/edit"): + return auth.ScopeDocsWrite, true + default: + return "", false + } +} + type statusWriter struct { http.ResponseWriter status int diff --git a/apps/server/internal/httpserver/rate_limit.go b/apps/server/internal/httpserver/rate_limit.go new file mode 100644 index 0000000..dc98828 --- /dev/null +++ b/apps/server/internal/httpserver/rate_limit.go @@ -0,0 +1,49 @@ +package httpserver + +import ( + "net/http" + "sync" + "time" +) + +type rateLimiter struct { + mu sync.Mutex + window time.Duration + limit int + attempts map[string][]time.Time +} + +func newRateLimiter(limit int, window time.Duration) *rateLimiter { + return &rateLimiter{ + window: window, + limit: limit, + attempts: make(map[string][]time.Time), + } +} + +func (l *rateLimiter) Allow(key string) bool { + now := time.Now() + cutoff := now.Add(-l.window) + + l.mu.Lock() + defer l.mu.Unlock() + + attempts := l.attempts[key] + kept := attempts[:0] + for _, attempt := range attempts { + if attempt.After(cutoff) { + kept = append(kept, attempt) + } + } + if len(kept) >= l.limit { + l.attempts[key] = kept + return false + } + kept = append(kept, now) + l.attempts[key] = kept + return true +} + +func authRateLimitKey(r *http.Request) string { + return requestIP(r) + ":" + r.URL.Path +} diff --git a/apps/server/internal/httpserver/rate_limit_test.go b/apps/server/internal/httpserver/rate_limit_test.go new file mode 100644 index 0000000..db03b26 --- /dev/null +++ b/apps/server/internal/httpserver/rate_limit_test.go @@ -0,0 +1,19 @@ +package httpserver + +import ( + "testing" + "time" +) + +func TestRateLimiterRejectsAfterLimit(t *testing.T) { + limiter := newRateLimiter(2, time.Minute) + if !limiter.Allow("ip:path") { + t.Fatal("first attempt should be allowed") + } + if !limiter.Allow("ip:path") { + t.Fatal("second attempt should be allowed") + } + if limiter.Allow("ip:path") { + t.Fatal("third attempt should be rate limited") + } +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index bb793f9..d601566 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/realtime" @@ -31,6 +32,7 @@ type Dependencies struct { Hub *realtime.Hub SyncService *sync.Service SyncRepo *sync.Repository + Auth *auth.Service } type Server struct { @@ -42,6 +44,8 @@ type Server struct { hub *realtime.Hub syncService *sync.Service syncRepo *sync.Repository + auth *auth.Service + authLimiter *rateLimiter templates *template.Template webEnabled bool } @@ -68,6 +72,8 @@ func New(deps Dependencies) (http.Handler, error) { hub: deps.Hub, syncService: deps.SyncService, syncRepo: deps.SyncRepo, + auth: deps.Auth, + authLimiter: newRateLimiter(5, time.Minute), templates: templates, } @@ -82,15 +88,37 @@ func New(deps Dependencies) (http.Handler, error) { router.Use(server.timeoutExceptWebSocket(30 * time.Second)) router.Use(server.requestLogger) router.Use(server.securityHeaders) - router.Use(server.apiKeyMiddleware) + router.Use(server.authMiddleware) router.Get("/", server.handleIndex) + router.Get("/login", server.handleLoginPage) + router.Get("/account", server.handleAccountPage) + router.Get("/device/verify", server.handleDeviceVerifyPage) router.Get("/health", server.handleHealth) router.Get("/sw.js", server.handleServiceWorker) router.Get("/ws", server.handleWebSocket) + router.Get("/api/auth/me", server.handleAuthMe) + router.Post("/api/auth/register/password", server.handlePasswordRegister) + router.Post("/api/auth/login/password", server.handlePasswordLogin) + router.Post("/api/auth/logout", server.handleLogout) + router.Post("/api/auth/password/change", server.handleChangePassword) + router.Delete("/api/auth/account", server.handleDeleteAccount) + router.Post("/api/auth/passkeys/register/begin", server.handlePasskeyRegisterBegin) + router.Post("/api/auth/passkeys/register/finish", server.handlePasskeyRegisterFinish) + router.Post("/api/auth/passkeys/login/begin", server.handlePasskeyLoginBegin) + router.Post("/api/auth/passkeys/login/finish", server.handlePasskeyLoginFinish) + router.Get("/api/tokens", server.handleListAPITokens) + router.Post("/api/tokens", server.handleCreateAPIToken) + router.Delete("/api/tokens/{id}", server.handleRevokeAPIToken) + router.Post("/api/device/start", server.handleDeviceStart) + router.Post("/api/device/approve", server.handleDeviceApprove) + router.Post("/api/device/token", server.handleDeviceToken) + router.Get("/api/device/verify", server.handleDeviceVerify) router.Post("/api/admin/login", server.handleAdminLogin) router.Get("/api/admin/users", server.handleAdminUsers) router.Post("/api/admin/users", server.handleAdminCreateUser) + router.Patch("/api/admin/users/{id}/role", server.handleAdminUpdateUserRole) + router.Get("/api/admin/auth-users", server.handleAdminAuthUsers) router.Get("/api/admin/workspace", server.handleAdminWorkspace) router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Get("/docs", server.handleDocsIndex) diff --git a/apps/server/internal/httpserver/static/auth.js b/apps/server/internal/httpserver/static/auth.js new file mode 100644 index 0000000..75e0f39 --- /dev/null +++ b/apps/server/internal/httpserver/static/auth.js @@ -0,0 +1,262 @@ +(() => { + const jsonHeaders = { "Content-Type": "application/json" }; + + const postJSON = async (url, body, options = {}) => { + const response = await fetch(url, { + method: options.method || "POST", + headers: jsonHeaders, + credentials: "same-origin", + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || `Request failed (${response.status})`); + } + return data; + }; + + const setStatus = (selector, message, isError = false) => { + const target = document.querySelector(selector); + if (!target) return; + target.textContent = message; + target.dataset.state = isError ? "error" : "ok"; + }; + + const authNext = () => document.querySelector("[data-auth-next]")?.value || "/account"; + + const formJSON = (form) => Object.fromEntries(new FormData(form).entries()); + + const base64URLToBuffer = (value) => { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; + }; + + const bufferToBase64URL = (value) => { + const bytes = new Uint8Array(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + }; + + const credentialToJSON = (credential) => { + if (credential instanceof ArrayBuffer) return bufferToBase64URL(credential); + if (ArrayBuffer.isView(credential)) return bufferToBase64URL(credential.buffer); + if (Array.isArray(credential)) return credential.map(credentialToJSON); + if (credential && typeof credential === "object") { + const next = {}; + for (const [key, value] of Object.entries(credential)) next[key] = credentialToJSON(value); + return next; + } + return credential; + }; + + const normalizeCreateOptions = (options) => { + const publicKey = { ...options.publicKey }; + publicKey.challenge = base64URLToBuffer(publicKey.challenge); + publicKey.user = { ...publicKey.user, id: base64URLToBuffer(publicKey.user.id) }; + publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map((credential) => ({ + ...credential, + id: base64URLToBuffer(credential.id), + })); + return { publicKey }; + }; + + const normalizeRequestOptions = (options) => { + const publicKey = { ...options.publicKey }; + publicKey.challenge = base64URLToBuffer(publicKey.challenge); + publicKey.allowCredentials = (publicKey.allowCredentials || []).map((credential) => ({ + ...credential, + id: base64URLToBuffer(credential.id), + })); + return { publicKey }; + }; + + document.querySelector("[data-auth-login]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postJSON("/api/auth/login/password", formJSON(event.currentTarget)); + window.location.href = authNext(); + } catch (error) { + setStatus("[data-login-status]", error.message, true); + } + }); + + document.querySelector("[data-auth-register]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postJSON("/api/auth/register/password", formJSON(event.currentTarget)); + setStatus("[data-register-status]", "Account created. Sign in with your password."); + event.currentTarget.reset(); + } catch (error) { + setStatus("[data-register-status]", error.message, true); + } + }); + + document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!window.PublicKeyCredential) { + setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true); + return; + } + try { + const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget)); + const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options)); + await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); + setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey."); + } catch (error) { + setStatus("[data-passkey-register-status]", error.message, true); + } + }); + + document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!window.PublicKeyCredential) { + setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true); + return; + } + try { + const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget)); + const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options)); + await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); + window.location.href = authNext(); + } catch (error) { + setStatus("[data-passkey-login-status]", error.message, true); + } + }); + + document.querySelector("[data-auth-logout]")?.addEventListener("click", async () => { + await postJSON("/api/auth/logout", {}); + window.location.href = "/login"; + }); + + const loadTokens = async () => { + const list = document.querySelector("[data-token-list]"); + if (!list) return; + const data = await fetch("/api/tokens", { credentials: "same-origin" }).then((response) => response.json()); + list.replaceChildren( + ...(data.tokens || []).map((token) => { + const item = document.createElement("li"); + const meta = document.createElement("span"); + meta.textContent = `${token.name} - ${token.scopes.join(", ")}${token.revokedAt ? " - revoked" : ""}`; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Revoke"; + button.disabled = Boolean(token.revokedAt); + button.addEventListener("click", async () => { + await fetch(`/api/tokens/${encodeURIComponent(token.id.replace(/^api:/, ""))}`, { + method: "DELETE", + credentials: "same-origin", + }); + await loadTokens(); + }); + item.append(meta, button); + return item; + }), + ); + }; + + document.querySelector("[data-token-create]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const scopes = [...form.querySelectorAll('input[name="scope"]:checked')].map((input) => input.value); + try { + const created = await postJSON("/api/tokens", { name: form.elements.name.value, scopes }); + const output = document.querySelector("[data-token-output]"); + if (output) { + output.hidden = false; + output.textContent = created.token; + } + setStatus("[data-token-status]", "Token created. Copy it now; it will not be shown again."); + form.reset(); + await loadTokens(); + } catch (error) { + setStatus("[data-token-status]", error.message, true); + } + }); + + document.querySelector("[data-password-change]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postJSON("/api/auth/password/change", formJSON(event.currentTarget)); + setStatus("[data-password-status]", "Password changed."); + event.currentTarget.reset(); + } catch (error) { + setStatus("[data-password-status]", error.message, true); + } + }); + + document.querySelector("[data-account-delete]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!confirm("Delete this account permanently?")) return; + try { + await postJSON("/api/auth/account", formJSON(event.currentTarget), { method: "DELETE" }); + window.location.href = "/login"; + } catch (error) { + setStatus("[data-delete-status]", error.message, true); + } + }); + + document.querySelector("[data-device-approve]")?.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postJSON("/api/device/approve", formJSON(event.currentTarget)); + setStatus("[data-device-status]", "Device approved."); + } catch (error) { + if (error.message === "authentication required") { + window.location.href = `/login?next=${encodeURIComponent(window.location.pathname + window.location.search)}`; + return; + } + setStatus("[data-device-status]", error.message, true); + } + }); + + const loadAdminUsers = async () => { + const section = document.querySelector("[data-admin-users-section]"); + const list = document.querySelector("[data-admin-users]"); + if (!section || !list) return; + const me = await fetch("/api/auth/me", { credentials: "same-origin" }).then((response) => response.json()).catch(() => null); + if (me?.principal?.role !== "admin") return; + section.hidden = false; + const data = await fetch("/api/admin/auth-users", { credentials: "same-origin" }).then((response) => response.json()); + list.replaceChildren( + ...(data.users || []).map((user) => { + const row = document.createElement("form"); + row.className = "user-row"; + const identity = document.createElement("span"); + const name = document.createElement("strong"); + name.textContent = user.displayName; + const email = document.createElement("small"); + email.textContent = user.email; + identity.append(name, email); + const select = document.createElement("select"); + for (const role of ["viewer", "editor", "admin"]) { + const option = document.createElement("option"); + option.value = role; + option.textContent = role; + option.selected = user.role === role; + select.append(option); + } + const button = document.createElement("button"); + button.type = "submit"; + button.textContent = "Save"; + row.append(identity, select, button); + row.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postJSON(`/api/admin/users/${encodeURIComponent(user.id)}/role`, { role: select.value }, { method: "PATCH" }); + setStatus("[data-admin-users-status]", "Role updated."); + } catch (error) { + setStatus("[data-admin-users-status]", error.message, true); + } + }); + return row; + }), + ); + }; + + loadTokens().catch(() => {}); + loadAdminUsers().catch(() => {}); +})(); diff --git a/apps/server/internal/httpserver/static/cache.js b/apps/server/internal/httpserver/static/cache.js index c8473d9..7886763 100644 --- a/apps/server/internal/httpserver/static/cache.js +++ b/apps/server/internal/httpserver/static/cache.js @@ -5,6 +5,7 @@ const STORE_CONTENT = "content"; const STORE_PAGES = "pages"; const STORE_PENDING_EDITS = "pending_edits"; + const CACHE_ENTRY_LIMIT = 100; function openDB() { return new Promise(function (resolve, reject) { @@ -39,6 +40,27 @@ return path.replace(/\/$/, "") || "/"; } + function pruneStore(store, limit) { + const request = store.getAll(); + request.onsuccess = function () { + const records = request.result || []; + if (records.length <= limit) { + return; + } + + records + .sort(function (a, b) { + return (a.cachedAt || 0) - (b.cachedAt || 0); + }) + .slice(0, records.length - limit) + .forEach(function (record) { + if (record && record.path) { + store.delete(record.path); + } + }); + }; + } + function cacheDocuments(documents) { return openDB().then(function (db) { return new Promise(function (resolve, reject) { @@ -63,6 +85,7 @@ const tx = db.transaction(STORE_CONTENT, "readwrite"); const store = tx.objectStore(STORE_CONTENT); store.put({ path: path, html: html, title: title, cachedAt: Date.now() }); + pruneStore(store, CACHE_ENTRY_LIMIT); tx.oncomplete = function () { resolve(); }; @@ -117,6 +140,7 @@ title: title, cachedAt: Date.now(), }); + pruneStore(store, CACHE_ENTRY_LIMIT); tx.oncomplete = function () { resolve(); }; diff --git a/apps/server/internal/httpserver/static/cairnquire logo@2x.webp b/apps/server/internal/httpserver/static/cairnquire logo@2x.webp index 81683b118c89954aacc9fd2ade0d2bffe0c303f5..66c1d429c31c35ed047eec8a12daea602018b8f2 100644 GIT binary patch literal 137310 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