Einladungscodes: registration_mode open|invite|closed, Admin-API /api/invites, App mit Code-Feld und Code-Verwaltung; v0.3.2

This commit is contained in:
OpenClaw Dev 2026-11-01 05:53:32 +01:00
parent d2658de099
commit cf5e0b1ca7
38 changed files with 665 additions and 25 deletions

View file

@ -215,8 +215,9 @@ func (s *Server) auth(h http.Handler) http.Handler {
}
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
InviteCode string `json:"invite_code,omitempty"`
}
func validUsername(u string) bool {
@ -247,7 +248,8 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, cod
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
s.initAuth()
if !s.AllowRegistration {
mode := s.regMode()
if mode == "closed" {
writeErr(w, http.StatusForbidden, "registration disabled")
return
}
@ -269,13 +271,24 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
return
}
code := ""
if mode == "invite" {
if code = store.NormalizeInviteCode(c.InviteCode); code == "" {
writeErr(w, http.StatusBadRequest, "invite code required")
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))
u, err := s.Store.CreateUserWithInvite(r.Context(), c.Username, string(hash), code)
if errors.Is(err, store.ErrInviteInvalid) || errors.Is(err, store.ErrInviteUsed) || errors.Is(err, store.ErrInviteExpired) {
writeErr(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, store.ErrUserExists) {
writeErr(w, http.StatusConflict, "username already taken")
return
@ -339,3 +352,15 @@ func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
}
return true
}
// regMode is the effective registration mode.
func (s *Server) regMode() string {
switch s.RegistrationMode {
case "open", "invite", "closed":
return s.RegistrationMode
}
if s.AllowRegistration {
return "open"
}
return "closed"
}

View file

@ -0,0 +1,54 @@
package server
import (
"net/http"
"time"
)
type inviteReq struct {
MaxUses int `json:"max_uses"`
ExpiresInHours int `json:"expires_in_hours"`
}
func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
var b inviteReq
if r.ContentLength != 0 && !decode(w, r, &b) {
return
}
if b.MaxUses < 0 || b.MaxUses > 1000 || b.ExpiresInHours < 0 || b.ExpiresInHours > 24*365 {
writeErr(w, http.StatusBadRequest, "max_uses must be 1-1000, expires_in_hours 0-8760")
return
}
inv, err := s.Store.CreateInvite(r.Context(), b.MaxUses, time.Duration(b.ExpiresInHours)*time.Hour)
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
list, err := s.Store.ListInvites(r.Context())
if err != nil {
s.storeErr(w, err)
return
}
writeJSON(w, 200, list)
}
func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request) {
if !requireAdmin(w, r) {
return
}
if err := s.Store.DeleteInvite(r.Context(), r.PathValue("code")); err != nil {
s.storeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,145 @@
package server
import (
"context"
"encoding/json"
"io"
"regexp"
"strings"
"sync"
"testing"
"time"
)
func regBody(u, p, code string) string {
b, _ := json.Marshal(map[string]string{"username": u, "password": p, "invite_code": code})
return string(b)
}
func TestInviteRegistration(t *testing.T) {
ts, st, s := authSetup(t, true)
s.RegistrationMode = "invite"
adm := bearerHdr(tok)
var cfg map[string]string
json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/config", "", nil).Body).Decode(&cfg)
if cfg["registration_mode"] != "invite" {
t.Fatalf("%v", cfg)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "password-1"), nil); r.StatusCode != 400 {
t.Fatal("no code must be 400", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", "BC-NOPE-NOPE"), nil); r.StatusCode != 403 {
t.Fatal("unknown code must be 403", r.StatusCode)
}
// only admin may manage codes
ut := ""
{
s.RegistrationMode = "open"
ut = registerUser(t, ts.URL, "bob", "password-1")
s.RegistrationMode = "invite"
}
for _, m := range []string{"GET", "POST"} {
if r := do(t, m, ts.URL+"/api/invites", "", bearerHdr(ut)); r.StatusCode != 403 {
t.Fatal(m, "invites must be admin only", r.StatusCode)
}
}
r := do(t, "POST", ts.URL+"/api/invites", "", adm)
if r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatal(r.StatusCode, string(b))
}
var inv struct {
Code string `json:"code"`
MaxUses int `json:"max_uses"`
}
json.NewDecoder(r.Body).Decode(&inv)
if !regexp.MustCompile(`^BC-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`).MatchString(inv.Code) || inv.MaxUses != 1 {
t.Fatalf("%+v", inv)
}
// lower-case input is accepted
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", " "+strings.ToLower(inv.Code)), nil); r.StatusCode != 201 {
b, _ := io.ReadAll(r.Body)
t.Fatal("register with code", r.StatusCode, string(b))
}
r = do(t, "POST", ts.URL+"/api/auth/register", regBody("carol", "password-1", inv.Code), nil)
b, _ := io.ReadAll(r.Body)
if r.StatusCode != 403 || !strings.Contains(string(b), "already used") {
t.Fatal("reuse", r.StatusCode, string(b))
}
// list shows usage
var list []struct {
Code string `json:"code"`
Uses int `json:"uses"`
UsedBy []string `json:"used_by"`
}
json.NewDecoder(do(t, "GET", ts.URL+"/api/invites", "", adm).Body).Decode(&list)
if len(list) != 1 || list[0].Uses != 1 || len(list[0].UsedBy) != 1 || list[0].UsedBy[0] != "alice" {
t.Fatalf("%+v", list)
}
// multi-use + revoke
r = do(t, "POST", ts.URL+"/api/invites", `{"max_uses":5,"expires_in_hours":24}`, adm)
json.NewDecoder(r.Body).Decode(&inv)
if inv.MaxUses != 5 {
t.Fatalf("%+v", inv)
}
if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 204 {
t.Fatal("revoke", r.StatusCode)
}
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("dave", "password-1", inv.Code), nil); r.StatusCode != 403 {
t.Fatal("revoked code", r.StatusCode)
}
if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 404 {
t.Fatal("revoke twice", r.StatusCode)
}
// expired
ex, _ := st.CreateInvite(context.Background(), 1, time.Millisecond)
time.Sleep(5 * time.Millisecond)
r = do(t, "POST", ts.URL+"/api/auth/register", regBody("erin", "password-1", ex.Code), nil)
b, _ = io.ReadAll(r.Body)
if r.StatusCode != 403 || !strings.Contains(string(b), "expired") {
t.Fatal("expired", r.StatusCode, string(b))
}
// closed mode
s.RegistrationMode = "closed"
if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("frank", "password-1", ""), nil); r.StatusCode != 403 {
t.Fatal("closed", r.StatusCode)
}
}
func TestInviteConsumedAtomically(t *testing.T) {
_, st, _ := authSetup(t, true)
ctx := context.Background()
inv, err := st.CreateInvite(ctx, 3, 0)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
var mu sync.Mutex
ok := 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if _, err := st.CreateUserWithInvite(ctx, "u"+string(rune('a'+i))+"xx", "h", inv.Code); err == nil {
mu.Lock()
ok++
mu.Unlock()
}
}(i)
}
wg.Wait()
if ok != 3 {
t.Fatalf("expected exactly 3 registrations, got %d", ok)
}
// failed user insert must not burn a use
inv2, _ := st.CreateInvite(ctx, 1, 0)
st.CreateUser(ctx, "dupe", "h")
if _, err := st.CreateUserWithInvite(ctx, "dupe", "h", inv2.Code); err == nil {
t.Fatal("duplicate username must fail")
}
if _, err := st.CreateUserWithInvite(ctx, "fresh", "h", inv2.Code); err != nil {
t.Fatal("code must still be usable after failed insert:", err)
}
}

View file

@ -37,6 +37,8 @@ type Server struct {
Providers *providers.Manager
// AllowRegistration enables POST /api/auth/register.
AllowRegistration bool
// RegistrationMode overrides AllowRegistration: "open", "invite" or "closed".
RegistrationMode string
// TokenTTL is the lifetime of user tokens (default 30 days).
TokenTTL time.Duration
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
@ -59,10 +61,16 @@ func (s *Server) Handler() http.Handler {
s.initAuth()
mux.HandleFunc("POST /api/auth/register", s.register)
mux.HandleFunc("POST /api/auth/login", s.login)
mux.HandleFunc("GET /api/auth/config", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]string{"registration_mode": s.regMode()})
})
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/invites", s.listInvites)
api.HandleFunc("POST /api/invites", s.createInvite)
api.HandleFunc("DELETE /api/invites/{code}", s.deleteInvite)
api.HandleFunc("GET /api/providers", s.listProviders)
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
api.HandleFunc("GET /api/providers/{name}/models", s.listModels)