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:
parent
d2658de099
commit
cf5e0b1ca7
38 changed files with 665 additions and 25 deletions
|
|
@ -80,6 +80,8 @@ type Config struct {
|
|||
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
||||
// AllowRegistration enables self-service sign-up (default true).
|
||||
AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"`
|
||||
// RegistrationMode: open | invite | closed. Empty = derived from allow_registration.
|
||||
RegistrationMode string `yaml:"registration_mode" json:"registration_mode"`
|
||||
// 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.
|
||||
|
|
@ -164,6 +166,7 @@ func applyEnv(c *Config, get func(string) string) {
|
|||
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
||||
c.AllowRegistration = &b
|
||||
}
|
||||
set("BYTECLAW_REGISTRATION_MODE", &c.RegistrationMode)
|
||||
if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" {
|
||||
c.AllowedOrigins = nil
|
||||
for _, o := range strings.Split(v, ",") {
|
||||
|
|
@ -177,12 +180,29 @@ 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 }
|
||||
|
||||
// Registration returns the effective registration mode (open, invite or closed).
|
||||
func (c Config) Registration() string {
|
||||
if m := strings.ToLower(strings.TrimSpace(c.RegistrationMode)); m != "" {
|
||||
return m
|
||||
}
|
||||
if c.RegistrationAllowed() {
|
||||
return "open"
|
||||
}
|
||||
return "closed"
|
||||
}
|
||||
|
||||
// Validate enforces required fields and secure settings.
|
||||
func (c Config) Validate() error {
|
||||
var errs []error
|
||||
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 m := c.Registration(); m != "open" && m != "invite" && m != "closed" {
|
||||
errs = append(errs, errors.New("registration_mode must be open, invite or closed"))
|
||||
}
|
||||
if c.Registration() == "invite" && c.AuthToken == "" {
|
||||
errs = append(errs, errors.New("registration_mode invite needs auth_token (only the admin can create invite codes)"))
|
||||
}
|
||||
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
||||
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
54
internal/server/invites.go
Normal file
54
internal/server/invites.go
Normal 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)
|
||||
}
|
||||
145
internal/server/invites_test.go
Normal file
145
internal/server/invites_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
163
internal/store/invites.go
Normal file
163
internal/store/invites.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInviteInvalid = errors.New("invalid invite code")
|
||||
ErrInviteUsed = errors.New("invite code already used")
|
||||
ErrInviteExpired = errors.New("invite code expired")
|
||||
)
|
||||
|
||||
// Invite is a registration code. ExpiresAt is zero when it never expires.
|
||||
type Invite struct {
|
||||
Code string `json:"code"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
Uses int `json:"uses"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UsedBy []string `json:"used_by"`
|
||||
}
|
||||
|
||||
// No 0/O/1/I/L to keep codes easy to read and type.
|
||||
const inviteAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
|
||||
func newInviteCode() (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString("BC-")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i == 4 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(inviteAlphabet))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(inviteAlphabet[n.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// NormalizeInviteCode upper-cases and trims user input ("bc-abcd-efgh " -> "BC-ABCD-EFGH").
|
||||
func NormalizeInviteCode(c string) string { return strings.ToUpper(strings.TrimSpace(c)) }
|
||||
|
||||
// CreateInvite stores a new random code. maxUses < 1 means 1; ttl <= 0 means no expiry.
|
||||
func (s *Store) CreateInvite(ctx context.Context, maxUses int, ttl time.Duration) (Invite, error) {
|
||||
if maxUses < 1 {
|
||||
maxUses = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
inv := Invite{MaxUses: maxUses, CreatedAt: now, UsedBy: []string{}}
|
||||
var exp int64
|
||||
if ttl > 0 {
|
||||
t := now.Add(ttl)
|
||||
inv.ExpiresAt = &t
|
||||
exp = t.UnixMilli()
|
||||
}
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
code, err := newInviteCode()
|
||||
if err != nil {
|
||||
return Invite{}, err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO invites(code,max_uses,uses,expires_at,created_at) VALUES(?,?,0,?,?)`, code, maxUses, exp, now.UnixMilli())
|
||||
if err == nil {
|
||||
inv.Code = code
|
||||
return inv, nil
|
||||
}
|
||||
if !strings.Contains(err.Error(), "UNIQUE") {
|
||||
return Invite{}, err
|
||||
}
|
||||
}
|
||||
return Invite{}, errors.New("could not generate unique invite code")
|
||||
}
|
||||
|
||||
// ListInvites returns all codes, newest first, with the usernames that used them.
|
||||
func (s *Store) ListInvites(ctx context.Context) ([]Invite, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT code,max_uses,uses,expires_at,created_at FROM invites ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Invite
|
||||
idx := map[string]int{}
|
||||
for rows.Next() {
|
||||
var inv Invite
|
||||
var exp, c int64
|
||||
if err := rows.Scan(&inv.Code, &inv.MaxUses, &inv.Uses, &exp, &c); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
inv.CreatedAt = time.UnixMilli(c).UTC()
|
||||
if exp > 0 {
|
||||
t := time.UnixMilli(exp).UTC()
|
||||
inv.ExpiresAt = &t
|
||||
}
|
||||
inv.UsedBy = []string{}
|
||||
idx[inv.Code] = len(out)
|
||||
out = append(out, inv)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ur, err := s.db.QueryContext(ctx, `SELECT code,username FROM invite_uses ORDER BY used_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer ur.Close()
|
||||
for ur.Next() {
|
||||
var code, name string
|
||||
if err := ur.Scan(&code, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i, ok := idx[code]; ok {
|
||||
out[i].UsedBy = append(out[i].UsedBy, name)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []Invite{}
|
||||
}
|
||||
return out, ur.Err()
|
||||
}
|
||||
|
||||
// DeleteInvite revokes a code. Users who already registered with it stay.
|
||||
func (s *Store) DeleteInvite(ctx context.Context, code string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM invites WHERE code=?`, NormalizeInviteCode(code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func consumeInvite(ctx context.Context, tx *sql.Tx, code string, now time.Time) error {
|
||||
var maxUses, uses int
|
||||
var exp int64
|
||||
err := tx.QueryRowContext(ctx, `SELECT max_uses,uses,expires_at FROM invites WHERE code=?`, code).Scan(&maxUses, &uses, &exp)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exp > 0 && now.UnixMilli() >= exp {
|
||||
return ErrInviteExpired
|
||||
}
|
||||
// Conditional update: never exceeds max_uses even under concurrency.
|
||||
res, err := tx.ExecContext(ctx, `UPDATE invites SET uses=uses+1 WHERE code=? AND uses<max_uses`, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrInviteUsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -63,6 +63,14 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
|
|||
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);
|
||||
CREATE TABLE IF NOT EXISTS invites(
|
||||
code TEXT PRIMARY KEY, max_uses INTEGER NOT NULL DEFAULT 1, uses INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS invite_uses(
|
||||
code TEXT NOT NULL REFERENCES invites(code) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username TEXT NOT NULL, used_at INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_uses_code ON invite_uses(code);
|
||||
`
|
||||
|
||||
// migrations add columns to databases created by older versions.
|
||||
|
|
|
|||
|
|
@ -26,12 +26,23 @@ 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) {
|
||||
return s.CreateUserWithInvite(ctx, username, passwordHash, "")
|
||||
}
|
||||
|
||||
// CreateUserWithInvite creates a user and, if code is non-empty, consumes one
|
||||
// use of that invite code in the same transaction (all or nothing).
|
||||
func (s *Store) CreateUserWithInvite(ctx context.Context, username, passwordHash, code string) (User, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if code != "" {
|
||||
if err := consumeInvite(ctx, tx, code, now); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||
return User{}, err
|
||||
|
|
@ -44,6 +55,11 @@ func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (
|
|||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
if code != "" {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO invite_uses(code,user_id,username,used_at) VALUES(?,?,?,?)`, code, id, username, now.UnixMilli()); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
||||
return User{}, err
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue