diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go index f94cdfa..0a34729 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -81,7 +81,7 @@ func run(cfgPath string, log *slog.Logger) error { p, _, err := pm.ForSession(ctx, sid) return p, err }} - srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, + srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL}, RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} diff --git a/config.example.yaml b/config.example.yaml index 7646181..d179374 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,7 +2,9 @@ # BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL, # BYTECLAW_LISTEN, BYTECLAW_DATA_DIR, BYTECLAW_WORKSPACE, BYTECLAW_ALLOWED_ORIGINS (comma separated) listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy) or in a trusted LAN -auth_token: "" # required, >= 16 chars. Generate: openssl rand -hex 32 +auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32 +allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account +token_ttl_hours: 720 # login token lifetime allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it data_dir: ./data workspace: "" # default: /workspace; file tools cannot leave it diff --git a/go.mod b/go.mod index 09ca5b2..23d4f17 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/crypto/byteclaw -go 1.25.0 +go 1.26.0 require ( github.com/coder/websocket v1.8.13 @@ -14,8 +14,9 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.49.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index bca909b..80e03e3 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= @@ -21,6 +23,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/config.go b/internal/config/config.go index 3ae6924..794b94a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,7 +76,12 @@ type Tools struct { type Config struct { Listen string `yaml:"listen" json:"listen"` + // AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth. AuthToken string `yaml:"auth_token" json:"auth_token"` + // AllowRegistration enables self-service sign-up (default true). + AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"` + // TokenTTLHours is the lifetime of login tokens (default 720 = 30 days). + TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"` // AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly. AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"` DataDir string `yaml:"data_dir" json:"data_dir"` @@ -153,6 +158,10 @@ func applyEnv(c *Config, get func(string) string) { set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL) set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey) set("BYTECLAW_LLM_MODEL", &c.LLM.Model) + if v := get("BYTECLAW_ALLOW_REGISTRATION"); v != "" { + b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") + c.AllowRegistration = &b + } if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" { c.AllowedOrigins = nil for _, o := range strings.Split(v, ",") { @@ -163,11 +172,14 @@ func applyEnv(c *Config, get func(string) string) { } } +// RegistrationAllowed reports allow_registration (default true). +func (c Config) RegistrationAllowed() bool { return c.AllowRegistration == nil || *c.AllowRegistration } + // Validate enforces required fields and secure settings. func (c Config) Validate() error { var errs []error - if len(c.AuthToken) < 16 { - errs = append(errs, errors.New("auth_token must be set and at least 16 characters (BYTECLAW_AUTH_TOKEN)")) + if c.AuthToken != "" && len(c.AuthToken) < 16 { + errs = append(errs, errors.New("auth_token must be at least 16 characters when set (BYTECLAW_AUTH_TOKEN)")) } if c.LLM.BaseURL == "" || c.LLM.Model == "" { errs = append(errs, errors.New("llm.base_url and llm.model are required")) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7d09438..f985f87 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -36,9 +36,21 @@ func TestLoadJSON(t *testing.T) { } } -func TestValidateRequiresToken(t *testing.T) { - t.Setenv("BYTECLAW_AUTH_TOKEN", "") +func TestValidateShortToken(t *testing.T) { + t.Setenv("BYTECLAW_AUTH_TOKEN", "short") if _, err := Load(""); err == nil { - t.Fatal("expected error without token") + t.Fatal("expected error for short token") + } + t.Setenv("BYTECLAW_AUTH_TOKEN", "") + c, err := Load("") + if err != nil { + t.Fatalf("token is optional now: %v", err) + } + if !c.RegistrationAllowed() { + t.Fatal("registration must default to true") + } + t.Setenv("BYTECLAW_ALLOW_REGISTRATION", "false") + if c, _ = Load(""); c.RegistrationAllowed() { + t.Fatal("env must disable registration") } } diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..bd481cc --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,284 @@ +package server + +import ( + "context" + "crypto/subtle" + "errors" + "net" + "net/http" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/crypto/bcrypt" + + "github.com/crypto/byteclaw/internal/store" +) + +// Principal is the authenticated caller. Admin (static auth_token) sees all sessions. +type Principal struct { + UserID int64 + Username string + Admin bool + token string +} + +// Scope is the user filter for store queries. +func (p Principal) Scope() int64 { + if p.Admin { + return store.AllUsers + } + return p.UserID +} + +// OwnerID is the user_id new sessions get. +func (p Principal) OwnerID() int64 { + if p.Admin { + return 0 + } + return p.UserID +} + +type ctxKey struct{} + +func principal(ctx context.Context) Principal { + p, _ := ctx.Value(ctxKey{}).(Principal) + return p +} + +const ( + defaultTokenTTL = 30 * 24 * time.Hour + loginWindow = 15 * time.Minute + loginMaxFails = 10 +) + +// dummyHash keeps the timing of "unknown user" close to "wrong password". +var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("byteclaw-dummy-password"), bcrypt.DefaultCost) + +// limiter counts failed attempts per IP in a fixed window. +type limiter struct { + mu sync.Mutex + hits map[string][]time.Time + max int + win time.Duration + now func() time.Time +} + +func newLimiter(max int, win time.Duration) *limiter { + return &limiter{hits: map[string][]time.Time{}, max: max, win: win, now: time.Now} +} + +func (l *limiter) prune(key string) []time.Time { + cut := l.now().Add(-l.win) + h := l.hits[key] + i := 0 + for i < len(h) && h[i].Before(cut) { + i++ + } + h = h[i:] + if len(h) == 0 { + delete(l.hits, key) + } else { + l.hits[key] = h + } + return h +} + +func (l *limiter) Blocked(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.prune(key)) >= l.max +} + +func (l *limiter) Fail(key string) { + l.mu.Lock() + defer l.mu.Unlock() + l.hits[key] = append(l.prune(key), l.now()) +} + +func (l *limiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.hits, key) +} + +func clientIP(r *http.Request) string { + h, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return h +} + +func (s *Server) initAuth() { + s.authOnce.Do(func() { + if s.limiter == nil { + s.limiter = newLimiter(loginMaxFails, loginWindow) + } + if s.TokenTTL <= 0 { + s.TokenTTL = defaultTokenTTL + } + }) +} + +func bearer(r *http.Request) string { + if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { + return strings.TrimSpace(t) + } + // Browsers cannot set headers on WebSocket upgrades: allow ?token= there only. + if r.URL.Path == "/api/ws" { + return r.URL.Query().Get("token") + } + return "" +} + +// auth accepts the static admin token (constant-time) or a user token. +func (s *Server) auth(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := bearer(r) + var p Principal + switch { + case got == "": + writeErr(w, http.StatusUnauthorized, "unauthorized") + return + case s.Token != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) == 1: + p = Principal{Admin: true, Username: "admin"} + default: + u, err := s.Store.UserByToken(r.Context(), got) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized") + return + } + p = Principal{UserID: u.ID, Username: u.Username} + } + p.token = got + h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p))) + }) +} + +type credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func validUsername(u string) bool { + if n := utf8.RuneCountInString(u); n < 3 || n > 32 { + return false + } + for _, c := range u { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { + return false + } + } + return true +} + +func validPassword(p string) bool { + // bcrypt only uses the first 72 bytes; reject longer input instead of silently truncating. + return utf8.RuneCountInString(p) >= 8 && len(p) <= 72 +} + +func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, code int) { + tok, exp, err := s.Store.IssueToken(r.Context(), u.ID, s.TokenTTL) + if err != nil { + s.storeErr(w, err) + return + } + writeJSON(w, code, map[string]any{"token": tok, "expires_at": exp, "user": map[string]any{"id": u.ID, "username": u.Username}}) +} + +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + s.initAuth() + if !s.AllowRegistration { + writeErr(w, http.StatusForbidden, "registration disabled") + return + } + ip := clientIP(r) + if s.limiter.Blocked(ip) { + writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") + return + } + var c credentials + if !decode(w, r, &c) { + return + } + c.Username = strings.TrimSpace(c.Username) + if !validUsername(c.Username) { + writeErr(w, http.StatusBadRequest, "username must be 3-32 characters (letters, digits, . _ -)") + return + } + if !validPassword(c.Password) { + writeErr(w, http.StatusBadRequest, "password must be 8-72 characters") + return + } + s.limiter.Fail(ip) // every registration counts against the IP budget + hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost) + if err != nil { + s.storeErr(w, err) + return + } + u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash)) + if errors.Is(err, store.ErrUserExists) { + writeErr(w, http.StatusConflict, "username already taken") + return + } + if err != nil { + s.storeErr(w, err) + return + } + s.issue(w, r, u, http.StatusCreated) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + s.initAuth() + ip := clientIP(r) + if s.limiter.Blocked(ip) { + writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") + return + } + var c credentials + if !decode(w, r, &c) { + return + } + u, err := s.Store.UserByName(r.Context(), strings.TrimSpace(c.Username)) + hash := dummyHash + if err == nil { + hash = []byte(u.PasswordHash()) + } else if !errors.Is(err, store.ErrNotFound) { + s.storeErr(w, err) + return + } + if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil { + s.limiter.Fail(ip) + writeErr(w, http.StatusUnauthorized, "invalid username or password") + return + } + s.limiter.Reset(ip) + s.issue(w, r, u, http.StatusOK) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + p := principal(r.Context()) + if !p.Admin { + if err := s.Store.RevokeToken(r.Context(), p.token); err != nil { + s.storeErr(w, err) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + p := principal(r.Context()) + writeJSON(w, 200, map[string]any{"id": p.UserID, "username": p.Username, "admin": p.Admin}) +} + +// owns writes 404 and returns false unless the caller may access session id. +func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool { + if err := s.Store.SessionOwnedBy(r.Context(), principal(r.Context()).Scope(), id); err != nil { + s.storeErr(w, err) + return false + } + return true +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..adce7bd --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,200 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/crypto/byteclaw/internal/agent" + "github.com/crypto/byteclaw/internal/store" + "github.com/crypto/byteclaw/internal/tools" +) + +func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) { + st, err := store.Open(filepath.Join(t.TempDir(), "s.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, + Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}} + ts := httptest.NewServer(s.Handler()) + t.Cleanup(ts.Close) + return ts, st, s +} + +type authResp struct { + Token string `json:"token"` + User struct { + ID int64 `json:"id"` + Username string `json:"username"` + } `json:"user"` +} + +func creds(u, p string) string { + b, _ := json.Marshal(map[string]string{"username": u, "password": p}) + return string(b) +} + +func registerUser(t *testing.T, base, u, p string) string { + t.Helper() + r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil) + if r.StatusCode != 201 { + b, _ := io.ReadAll(r.Body) + t.Fatalf("register %s: %d %s", u, r.StatusCode, b) + } + var a authResp + json.NewDecoder(r.Body).Decode(&a) + if a.Token == "" || a.User.Username != u { + t.Fatalf("bad register response %+v", a) + } + return a.Token +} + +func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} } + +func TestRegisterLoginMeLogout(t *testing.T) { + ts, _, _ := authSetup(t, true) + t1 := registerUser(t, ts.URL, "alice", "supersecret") + if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 { + t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode) + } + r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)) + var me map[string]any + json.NewDecoder(r.Body).Decode(&me) + if r.StatusCode != 200 || me["username"] != "alice" { + t.Fatalf("me: %d %v", r.StatusCode, me) + } + if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 { + t.Fatalf("wrong password: %d", r.StatusCode) + } + if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 { + t.Fatalf("unknown user: %d", r.StatusCode) + } + r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil) + var a authResp + json.NewDecoder(r.Body).Decode(&a) + if r.StatusCode != 200 || a.Token == "" || a.Token == t1 { + t.Fatalf("login: %d", r.StatusCode) + } + if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 { + t.Fatalf("logout: %d", r.StatusCode) + } + if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 { + t.Fatalf("revoked token must be 401, got %d", r.StatusCode) + } + if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 { + t.Fatalf("other token must stay valid, got %d", r.StatusCode) + } +} + +func TestRegisterValidation(t *testing.T) { + ts, _, _ := authSetup(t, true) + for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} { + if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 { + t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode) + } + } +} + +func TestRegistrationDisabled(t *testing.T) { + ts, _, _ := authSetup(t, false) + if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 { + t.Fatalf("want 403 got %d", r.StatusCode) + } +} + +func TestAuthRequired(t *testing.T) { + ts, _, _ := authSetup(t, true) + for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} { + if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 { + t.Fatalf("%s without token: %d", p, r.StatusCode) + } + if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 { + t.Fatalf("%s bad token: %d", p, r.StatusCode) + } + } + if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 { + t.Fatalf("query token must only work for ws, got %d", r.StatusCode) + } + // static admin token still works + if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 { + t.Fatalf("static token: %d", r.StatusCode) + } +} + +func TestPerUserIsolation(t *testing.T) { + ts, st, _ := authSetup(t, true) + legacy, _ := st.CreateSession(t.Context(), "legacy") + ta := registerUser(t, ts.URL, "alice", "supersecret") + tb := registerUser(t, ts.URL, "bob_2", "supersecret") + r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb)) + var bs store.Session + json.NewDecoder(r.Body).Decode(&bs) + + list := func(tk string) []store.Session { + var l []store.Session + json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l) + return l + } + if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID { + t.Fatalf("first user must adopt legacy session, got %+v", l) + } + if l := list(tb); len(l) != 1 || l[0].ID != bs.ID { + t.Fatalf("bob sees %+v", l) + } + for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} { + if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 { + t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode) + } + } + if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 { + t.Fatalf("rename foreign: %d", r.StatusCode) + } + if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 { + t.Fatalf("chat into foreign session: %d", r.StatusCode) + } + if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 { + t.Fatalf("own chat: %d", r.StatusCode) + } + if l := list(tb); len(l) != 1 { + t.Fatalf("alice's new chat leaked to bob: %+v", l) + } + if l := list(tok); len(l) != 3 { + t.Fatalf("admin must see all sessions, got %d", len(l)) + } +} + +func TestLoginRateLimit(t *testing.T) { + ts, _, s := authSetup(t, true) + registerUser(t, ts.URL, "alice", "supersecret") + s.limiter.Reset("127.0.0.1") + for i := 0; i < loginMaxFails; i++ { + if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 { + t.Fatalf("attempt %d: %d", i, r.StatusCode) + } + } + if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 { + t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode) + } + // window expiry unblocks + s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) } + if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 { + t.Fatalf("after window: %d", r.StatusCode) + } +} + +func TestWSQueryToken(t *testing.T) { + ts, _, _ := authSetup(t, true) + tk := registerUser(t, ts.URL, "alice", "supersecret") + r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="}) + if r.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("ws with query token: %d", r.StatusCode) + } +} diff --git a/internal/server/extra.go b/internal/server/extra.go index 7dcb852..1594c14 100644 --- a/internal/server/extra.go +++ b/internal/server/extra.go @@ -90,8 +90,7 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { return } id := r.PathValue("id") - if _, err := s.Store.GetSession(r.Context(), id); err != nil { - s.storeErr(w, err) + if !s.owns(w, r, id) { return } var a providers.Active @@ -110,13 +109,15 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { } func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { + if !s.owns(w, r, r.PathValue("id")) { + return + } writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))}) } func (s *Server) summarize(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if _, err := s.Store.GetSession(r.Context(), id); err != nil { - s.storeErr(w, err) + if !s.owns(w, r, id) { return } sm, err := s.Agent.SummarizeNow(r.Context(), id) @@ -133,6 +134,9 @@ func (s *Server) summarize(w http.ResponseWriter, r *http.Request) { } func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { + if !s.owns(w, r, r.PathValue("id")) { + return + } sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id")) if err != nil { if errors.Is(err, store.ErrNotFound) { diff --git a/internal/server/server.go b/internal/server/server.go index 435b167..5e2afb9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,8 +3,7 @@ package server import ( "context" - "crypto/subtle" - "encoding/json" + "encoding/json" "errors" "log/slog" "net/http" @@ -35,6 +34,13 @@ type Server struct { Info Info // Providers enables runtime provider switching (optional). Providers *providers.Manager + // AllowRegistration enables POST /api/auth/register. + AllowRegistration bool + // TokenTTL is the lifetime of user tokens (default 30 days). + TokenTTL time.Duration + + authOnce sync.Once + limiter *limiter } type Info struct { @@ -47,7 +53,12 @@ type Info struct { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) + s.initAuth() + mux.HandleFunc("POST /api/auth/register", s.register) + mux.HandleFunc("POST /api/auth/login", s.login) api := http.NewServeMux() + api.HandleFunc("POST /api/auth/logout", s.logout) + api.HandleFunc("GET /api/auth/me", s.me) api.HandleFunc("GET /api/info", s.info) api.HandleFunc("GET /api/providers", s.listProviders) api.HandleFunc("PUT /api/providers/active", s.setActiveProvider) @@ -64,8 +75,10 @@ func (s *Server) Handler() http.Handler { api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages) api.HandleFunc("POST /api/chat", s.chat) api.HandleFunc("GET /api/ws", s.ws) - mux.Handle("/api/", s.originCheck(s.auth(api))) - return securityHeaders(mux) + mux.Handle("/api/", s.auth(api)) + h := http.Handler(mux) + h = s.originCheck(h) + return securityHeaders(h) } func securityHeaders(h http.Handler) http.Handler { @@ -76,18 +89,6 @@ func securityHeaders(h http.Handler) http.Handler { }) } -// auth requires "Authorization: Bearer " (constant-time compare). -func (s *Server) auth(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") - if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 { - writeErr(w, http.StatusUnauthorized, "unauthorized") - return - } - h.ServeHTTP(w, r) - }) -} - // OriginAllowed: requests without Origin (native apps, curl) pass; browser // requests must come from the same host or an explicitly configured origin. func (s *Server) OriginAllowed(r *http.Request) bool { @@ -158,7 +159,7 @@ func (s *Server) storeErr(w http.ResponseWriter, err error) { } func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { - list, err := s.Store.ListSessions(r.Context(), 200) + list, err := s.Store.ListSessionsFor(r.Context(), principal(r.Context()).Scope(), 200) if err != nil { s.storeErr(w, err) return @@ -173,7 +174,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { if r.ContentLength != 0 && !decode(w, r, &b) { return } - ss, err := s.Store.CreateSession(r.Context(), b.Title) + ss, err := s.Store.CreateSessionFor(r.Context(), principal(r.Context()).OwnerID(), b.Title) if err != nil { s.storeErr(w, err) return @@ -182,6 +183,9 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { } func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { + if !s.owns(w, r, r.PathValue("id")) { + return + } ss, err := s.Store.GetSession(r.Context(), r.PathValue("id")) if err != nil { s.storeErr(w, err) @@ -197,6 +201,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &b) { return } + if !s.owns(w, r, r.PathValue("id")) { + return + } if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil { s.storeErr(w, err) return @@ -205,6 +212,9 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { + if !s.owns(w, r, r.PathValue("id")) { + return + } if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil { s.storeErr(w, err) return @@ -214,8 +224,7 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if _, err := s.Store.GetSession(r.Context(), id); err != nil { - s.storeErr(w, err) + if !s.owns(w, r, id) { return } msgs, err := s.Store.Messages(r.Context(), id, 0) @@ -256,14 +265,13 @@ func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) // ensureSession returns an existing session id or creates one titled after the message. func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) { if id != "" { - _, err := s.Store.GetSession(ctx, id) - return id, err + return id, s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), id) } title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) if utf8.RuneCountInString(title) > 48 { title = string([]rune(title)[:48]) + "…" } - ss, err := s.Store.CreateSession(ctx, title) + ss, err := s.Store.CreateSessionFor(ctx, principal(ctx).OwnerID(), title) return ss.ID, err } @@ -337,7 +345,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { send(agent.Event{Type: "pong"}) continue case "cancel": - if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) { + if req.SessionID == "" || s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), req.SessionID) != nil || !s.Agent.Cancel(req.SessionID) { send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID}) } continue diff --git a/internal/store/store.go b/internal/store/store.go index 5656a2b..298498b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -54,6 +54,15 @@ CREATE TABLE IF NOT EXISTS summaries( upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id); CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, created_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS auth_tokens( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL); +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); ` // migrations add columns to databases created by older versions. @@ -61,6 +70,7 @@ var migrations = []struct{ table, column, ddl string }{ {"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"}, {"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"}, {"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"}, + {"sessions", "user_id", "ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"}, } func migrate(db *sql.DB) error { @@ -103,10 +113,15 @@ func newID() string { } func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) { + return s.CreateSessionFor(ctx, 0, title) +} + +// CreateSessionFor creates a session owned by userID (0 = legacy/admin). +func (s *Store) CreateSessionFor(ctx context.Context, userID int64, title string) (Session, error) { now := time.Now().UTC() ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now} - _, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`, - ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli()) + _, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,user_id,created_at,updated_at) VALUES(?,?,?,?,?)`, + ss.ID, ss.Title, userID, now.UnixMilli(), now.UnixMilli()) return ss, err } @@ -122,7 +137,16 @@ func (s *Store) GetSession(ctx context.Context, id string) (Session, error) { } func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit) + return s.ListSessionsFor(ctx, AllUsers, limit) +} + +// AllUsers disables per-user scoping (admin / static token). +const AllUsers int64 = -1 + +// ListSessionsFor lists the sessions of userID (AllUsers = every session). +func (s *Store) ListSessionsFor(ctx context.Context, userID int64, limit int) ([]Session, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions + WHERE (?<0 OR user_id=?) ORDER BY updated_at DESC LIMIT ?`, userID, userID, limit) if err != nil { return nil, err } diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..1e2820f --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "time" +) + +var ErrUserExists = errors.New("username already taken") + +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + hash string +} + +func (u User) PasswordHash() string { return u.hash } + +// CreateUser inserts a user. The first user ever created adopts all sessions +// that existed before accounts were introduced (user_id 0). +func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) { + now := time.Now().UTC() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return User{}, err + } + defer tx.Rollback() + var n int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + return User{}, err + } + res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli()) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return User{}, ErrUserExists + } + return User{}, err + } + id, _ := res.LastInsertId() + if n == 0 { + if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil { + return User{}, err + } + } + return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit() +} + +func (s *Store) UserByName(ctx context.Context, username string) (User, error) { + var u User + var c int64 + err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.CreatedAt = time.UnixMilli(c).UTC() + return u, err +} + +func hashToken(t string) string { + h := sha256.Sum256([]byte(t)) + return hex.EncodeToString(h[:]) +} + +// IssueToken creates a random opaque token; only its SHA-256 is stored. +func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", time.Time{}, err + } + tok := "bc_" + base64.RawURLEncoding.EncodeToString(b) + now := time.Now().UTC() + exp := now.Add(ttl) + _, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, + hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli()) + s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c) + if errors.Is(err, sql.ErrNoRows) { + return u, ErrNotFound + } + u.CreatedAt = time.UnixMilli(c).UTC() + return u, err +} + +func (s *Store) RevokeToken(ctx context.Context, tok string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok)) + return err +} + +// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any). +func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n) + if err == nil && n == 0 { + return ErrNotFound + } + return err +}