auth: Registrierung, Login, Logout, per-user Sessions (Backend)

This commit is contained in:
OpenClaw Dev 2026-10-31 11:28:17 +01:00
parent c7b4acea02
commit 0ef39cffd6
12 changed files with 703 additions and 40 deletions

View file

@ -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"))

View file

@ -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")
}
}

284
internal/server/auth.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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 <token>" (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

View file

@ -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
}

112
internal/store/users.go Normal file
View file

@ -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<?`, now.UnixMilli())
return tok, exp, err
}
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
var u User
var c int64
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
WHERE t.token_hash=? AND t.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
}