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 81683b1..66c1d42 100644 Binary files a/apps/server/internal/httpserver/static/cairnquire logo@2x.webp and b/apps/server/internal/httpserver/static/cairnquire logo@2x.webp differ diff --git a/apps/server/internal/httpserver/static/favicon.png b/apps/server/internal/httpserver/static/favicon.png index d49d342..d0225bf 100644 Binary files a/apps/server/internal/httpserver/static/favicon.png and b/apps/server/internal/httpserver/static/favicon.png differ diff --git a/apps/server/internal/httpserver/static/render.js b/apps/server/internal/httpserver/static/render.js index 30b1926..22ab01a 100644 --- a/apps/server/internal/httpserver/static/render.js +++ b/apps/server/internal/httpserver/static/render.js @@ -10,7 +10,9 @@ pre.replaceWith(container); }); mermaid.initialize({ startOnLoad: false }); - mermaid.run({ querySelector: ".mermaid" }); + mermaid.run({ querySelector: ".mermaid:not([data-processed])" }).catch(function (error) { + console.warn("Mermaid render failed:", error); + }); } function renderMath() { @@ -66,8 +68,8 @@ } function init() { - renderMermaid(); renderMath(); + renderMermaid(); } window.renderMermaid = renderMermaid; diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 8bd439f..ff4634a 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -152,6 +152,229 @@ code { height: calc(100vh - 64px); } +.auth-shell, +.account-shell { + min-height: 100%; + overflow: auto; + padding: clamp(1rem, 3vw, 2.5rem); +} + +.auth-shell { + display: grid; + align-items: start; + justify-items: center; +} + +.auth-panel, +.account-section { + width: min(100%, 920px); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel); + box-shadow: var(--shadow); +} + +.auth-panel { + padding: clamp(1rem, 3vw, 2rem); +} + +.auth-panel__header, +.account-header { + margin-bottom: 1.25rem; +} + +.auth-panel h1, +.account-header h1 { + margin: 0.1rem 0 0; + font-size: clamp(2rem, 4vw, 3.25rem); + line-height: 1; +} + +.eyebrow { + margin: 0; + color: var(--muted); + font: 700 0.72rem/1.2 ui-monospace, SFMono-Regular, monospace; + letter-spacing: 0; + text-transform: uppercase; +} + +.auth-grid, +.account-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 1rem; +} + +.auth-form { + display: grid; + gap: 0.75rem; +} + +.auth-form h2, +.account-section h2 { + margin: 0; + font-size: 1.05rem; +} + +.auth-form label, +.scope-grid { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.9rem; +} + +.auth-form input, +.auth-form select, +.user-row select { + width: 100%; + padding: 0.55rem 0.65rem; + border: 1px solid var(--border); + border-radius: 0; + background: var(--panel-strong); + color: var(--text); + font: inherit; +} + +.auth-form button, +.account-header button, +.token-list button, +.user-row button { + min-height: 2.35rem; + padding: 0.45rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.auth-form button[type="submit"], +.account-header button { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); + background: var(--accent); + color: white; +} + +.auth-details { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.auth-details summary { + cursor: pointer; + color: var(--accent); + font-weight: 700; +} + +.form-status { + min-height: 1.2rem; + margin: 0; + color: var(--muted); + font-size: 0.88rem; +} + +.form-status[data-state="error"] { + color: #b42318; +} + +.form-status[data-state="ok"] { + color: #067647; +} + +.auth-note { + margin: 1rem 0 0; + color: var(--muted); +} + +.account-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: min(100%, 1120px); +} + +.account-header p { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.account-grid { + width: min(100%, 1120px); +} + +.account-section { + padding: 1rem; +} + +.account-section--danger { + border-color: color-mix(in srgb, #b42318 40%, var(--border)); +} + +.scope-grid { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + margin: 0; + padding: 0.75rem; + border: 1px solid var(--border); +} + +.scope-grid legend { + padding: 0 0.25rem; + color: var(--text); +} + +.scope-grid label { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.token-output { + max-width: 100%; + overflow: auto; + padding: 0.75rem; + border: 1px solid var(--border); + background: var(--panel-strong); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.token-list, +.user-list { + display: grid; + gap: 0.6rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.token-list li, +.user-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + padding: 0.75rem; + border: 1px solid var(--border); + background: var(--panel-strong); +} + +.user-row { + grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem) auto; +} + +.user-row span { + display: grid; + gap: 0.15rem; +} + +.user-row small { + color: var(--muted); +} + /* Workspace layout - adaptive grid */ .workspace-shell { display: grid; @@ -583,7 +806,9 @@ code { } .markdown-body img { - max-width: 100%; + display: block; + max-width: min(85%, 100%); + height: auto; } .version-notice { @@ -640,6 +865,96 @@ code { display: none; } +.sync-queue { + position: fixed; + left: 1rem; + bottom: 7.9rem; + z-index: 30; + display: grid; + gap: 0.65rem; + width: min(28rem, calc(100vw - 2rem)); + padding: 0.75rem 0.85rem; + border: 1px solid rgba(146, 64, 14, 0.24); + background: rgba(255, 251, 235, 0.97); + color: #78350f; + box-shadow: 0 18px 48px rgba(24, 32, 42, 0.12); + font-size: 0.9rem; +} + +.sync-queue[hidden] { + display: none; +} + +.sync-queue__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.sync-queue__header strong, +.sync-queue__header p { + margin: 0; +} + +.sync-queue__header p { + color: #92400e; + font-size: 0.84rem; +} + +.sync-queue button { + min-height: 2rem; + flex: 0 0 auto; + padding: 0 0.65rem; + border: 1px solid rgba(146, 64, 14, 0.25); + background: #fff; + color: #78350f; + cursor: pointer; + font: 700 0.82rem/1 ui-monospace, SFMono-Regular, monospace; +} + +.sync-queue button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.sync-queue ul { + display: grid; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.sync-queue li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; + color: #78350f; +} + +.sync-queue li span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, monospace; +} + +.sync-queue li span:last-child { + flex: 0 0 auto; + color: #92400e; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +.sync-queue li[data-state="conflict"] span:last-child { + color: #b91c1c; +} + .offline-icon { display: inline-block; width: 0.6rem; @@ -1231,6 +1546,29 @@ code { background: oklch(0.60 0.15 250); } + .sync-queue { + border-color: oklch(0.65 0.12 75 / 0.32); + background: oklch(0.21 0.035 75 / 0.96); + color: oklch(0.75 0.12 75); + box-shadow: 0 18px 48px oklch(0.08 0.02 55 / 0.32); + } + + .sync-queue__header p, + .sync-queue li, + .sync-queue li span:last-child { + color: oklch(0.72 0.11 75); + } + + .sync-queue button { + border-color: oklch(0.65 0.12 75 / 0.28); + background: oklch(0.17 0.018 55); + color: oklch(0.75 0.12 75); + } + + .sync-queue li[data-state="conflict"] span:last-child { + color: oklch(0.68 0.16 25); + } + .version-notice button { background: var(--accent); } diff --git a/apps/server/internal/httpserver/static/sw.js b/apps/server/internal/httpserver/static/sw.js index 0ce142a..09881ed 100644 --- a/apps/server/internal/httpserver/static/sw.js +++ b/apps/server/internal/httpserver/static/sw.js @@ -1,4 +1,4 @@ -const STATIC_CACHE = "md-hub-static-v1"; +const STATIC_CACHE = "md-hub-static-v2"; const DB_NAME = "md-hub-cache"; const DB_VERSION = 3; const STORE_PAGES = "pages"; diff --git a/apps/server/internal/httpserver/static/sync.js b/apps/server/internal/httpserver/static/sync.js index 77ffe52..6f20be5 100644 --- a/apps/server/internal/httpserver/static/sync.js +++ b/apps/server/internal/httpserver/static/sync.js @@ -3,11 +3,107 @@ return; } + const syncQueue = document.querySelector("[data-sync-queue]"); + const syncQueueTitle = document.querySelector("[data-sync-queue-title]"); + const syncQueueSummary = document.querySelector("[data-sync-queue-summary]"); + const syncQueueList = document.querySelector("[data-sync-queue-list]"); + const syncNow = document.querySelector("[data-sync-now]"); + function normalizeDocPath(path) { if (!path) return ""; return path.replace(/^\/+/, ""); } + function formatPath(path) { + return (path || "").replace(/^\/docs\//, "") || "/"; + } + + function pluralize(count, singular, plural) { + return count === 1 ? singular : plural; + } + + function emitSyncState(detail) { + window.dispatchEvent(new CustomEvent("mdhub:sync-state", { detail: detail })); + } + + function renderPendingEdits(pending, state) { + if (!syncQueue || !syncQueueTitle || !syncQueueSummary || !syncQueueList) { + return; + } + + const conflicts = pending.filter(function (edit) { + return Boolean(edit.conflict); + }); + const clean = pending.filter(function (edit) { + return !edit.conflict; + }); + + syncQueue.hidden = pending.length === 0; + if (pending.length === 0) { + syncQueueList.replaceChildren(); + syncQueueTitle.textContent = "Synced"; + syncQueueSummary.textContent = "No pending changes."; + if (syncNow) { + syncNow.disabled = true; + } + return; + } + + const countLabel = pending.length + " pending " + pluralize(pending.length, "change", "changes"); + syncQueueTitle.textContent = conflicts.length > 0 ? countLabel + " with conflicts" : countLabel; + if (!navigator.onLine) { + syncQueueSummary.textContent = "Offline. Changes will sync when the connection returns."; + } else if (state === "syncing") { + syncQueueSummary.textContent = "Syncing queued edits..."; + } else if (conflicts.length > 0) { + syncQueueSummary.textContent = conflicts.length + " " + pluralize(conflicts.length, "file needs", "files need") + " conflict resolution."; + } else { + syncQueueSummary.textContent = "Queued edits are ready to sync."; + } + + syncQueueList.replaceChildren(); + pending.slice(0, 5).forEach(function (edit) { + const item = document.createElement("li"); + item.dataset.state = edit.conflict ? "conflict" : "queued"; + + const path = document.createElement("span"); + path.textContent = formatPath(edit.path); + item.appendChild(path); + + const status = document.createElement("span"); + status.textContent = edit.conflict ? "Conflict" : "Queued"; + item.appendChild(status); + + syncQueueList.appendChild(item); + }); + + if (pending.length > 5) { + const overflow = document.createElement("li"); + overflow.textContent = "+" + (pending.length - 5) + " more"; + syncQueueList.appendChild(overflow); + } + + if (syncNow) { + syncNow.disabled = !navigator.onLine || clean.length === 0 || state === "syncing"; + } + } + + async function refreshSyncQueue(state) { + const pending = await window.MDHubCache.getPendingEdits(); + pending.sort(function (a, b) { + return (b.updatedAt || 0) - (a.updatedAt || 0); + }); + renderPendingEdits(pending, state || "idle"); + emitSyncState({ + state: state || "idle", + pending: pending.length, + conflicts: pending.filter(function (edit) { + return Boolean(edit.conflict); + }).length, + }); + return pending; + } + async function postDocument(path, content, baseHash) { const response = await fetch("/api/documents/" + normalizeDocPath(path), { method: "POST", @@ -40,6 +136,7 @@ try { const result = await postDocument(path, content, baseHash); await window.MDHubCache.deletePendingEdit(normalizedPath); + await refreshSyncQueue("saved"); return { status: "saved", result: result, @@ -48,12 +145,14 @@ if (error.name === "DocumentConflictError") { await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash); await window.MDHubCache.markPendingEditConflict(normalizedPath, error.details); + await refreshSyncQueue("conflict"); return { status: "conflict", conflict: error.details, }; } await window.MDHubCache.putPendingEdit(normalizedPath, content, baseHash); + await refreshSyncQueue("queued"); return { status: "queued", }; @@ -62,15 +161,19 @@ async function syncPending() { if (!navigator.onLine) { + await refreshSyncQueue("offline"); return; } - const pending = await window.MDHubCache.getPendingEdits(); + const pending = await refreshSyncQueue("syncing"); pending.sort(function (a, b) { return (a.updatedAt || 0) - (b.updatedAt || 0); }); for (const edit of pending) { + if (edit.conflict) { + continue; + } try { await postDocument(edit.path.replace(/^\/docs\//, ""), edit.content, edit.baseHash); await window.MDHubCache.deletePendingEdit(edit.path); @@ -81,16 +184,31 @@ break; } } + await refreshSyncQueue("idle"); } window.addEventListener("online", function () { syncPending().catch(function () {}); }); + window.addEventListener("offline", function () { + refreshSyncQueue("offline").catch(function () {}); + }); + + if (syncNow) { + syncNow.addEventListener("click", function () { + syncPending().catch(function () { + refreshSyncQueue("queued").catch(function () {}); + }); + }); + } + syncPending().catch(function () {}); + refreshSyncQueue("idle").catch(function () {}); window.MDHubSync = { saveDocument: saveDocument, syncPending: syncPending, + refreshSyncQueue: refreshSyncQueue, }; })(); diff --git a/apps/server/internal/httpserver/sync_handlers.go b/apps/server/internal/httpserver/sync_handlers.go index aae6afe..d577607 100644 --- a/apps/server/internal/httpserver/sync_handlers.go +++ b/apps/server/internal/httpserver/sync_handlers.go @@ -1,11 +1,9 @@ package httpserver import ( - "context" "encoding/json" "net/http" "os" - "strings" "github.com/go-chi/chi/v5" @@ -24,9 +22,13 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) { return } - userID := r.Context().Value("userID").(string) + principal, ok := requirePrincipal(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } - snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, userID) + snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID) if err != nil { s.logger.Error("sync init failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"}) @@ -112,30 +114,3 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write(content) } - -func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip for non-sync routes - if !strings.HasPrefix(r.URL.Path, "/api/sync/") && !strings.HasPrefix(r.URL.Path, "/api/content/") { - next.ServeHTTP(w, r) - return - } - - apiKey := r.Header.Get("X-API-Key") - if apiKey == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing API key"}) - return - } - - // Validate API key against database - record, err := s.syncRepo.ValidateAPIKey(r.Context(), apiKey) - if err != nil { - s.logger.Warn("invalid api key", "error", err) - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid API key"}) - return - } - - ctx := context.WithValue(r.Context(), "userID", record.UserID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} diff --git a/apps/server/internal/httpserver/templates/account.gohtml b/apps/server/internal/httpserver/templates/account.gohtml new file mode 100644 index 0000000..3024d2e --- /dev/null +++ b/apps/server/internal/httpserver/templates/account.gohtml @@ -0,0 +1,72 @@ +{{ define "account.gohtml" }}{{ template "base" . }}{{ end }} + +{{ define "account_content" }} +
+
+
+

Account

+

{{ .DisplayName }}

+

{{ .Email }} · {{ .Role }}

+
+ +
+ +
+ + + + + + + +
+
+{{ end }} diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index f46a228..5bde2b5 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -19,7 +19,7 @@