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

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