diff --git a/deploy/examples/server.yaml b/deploy/examples/server.yaml index 9cea459..19c4167 100644 --- a/deploy/examples/server.yaml +++ b/deploy/examples/server.yaml @@ -65,6 +65,12 @@ agent_listen: ":7443" # # with a bad key cannot become a database read per request. # negative_ttl: 5s # +# # Serve the signup form. What it creates is a pending account that can do +# # nothing until `llmgw users approve` places it in a project, so this is not +# # a way in — it only moves key creation from you to the person who needs one. +# # Off, there is no form and you issue keys with `llmgw keys create --claim`. +# signup_open: true +# # # What each model costs, in micro-units per thousand tokens. A unit is # # whatever you decide: self-hosted models have no per-token price, so it can # # be GPU-seconds, a notional rate, or 1000 everywhere — which turns budgets diff --git a/docs/onboarding.md b/docs/onboarding.md index 6119afb..d162ead 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -3,18 +3,81 @@ Written for whoever operates an llmgw deployment: the person with a shell on the control plane machine. -Everything here runs as the user the server runs as, so the files it creates stay -readable by it: +There are two ways a person gets a key. Pick one and put it in the config. + +| | Self-service | Operator-issued | +|---|---|---| +| Config | `auth.signup_open: true` | `auth.signup_open: false` | +| They do | sign up, wait, then make their own keys | wait for a link | +| You do | approve once | issue every key | +| Good for | more than a handful of people | a few, or machines | + +Self-service does not mean open. A signup produces an account that can do +nothing at all; **approval is what makes it work**, and approval needs a shell +here rather than a session cookie, because it is the step that decides who gets +to spend the cluster. + +Everything below runs as the user the server runs as, so the files it creates +stay readable by it: ``` -sudo -u llmgw llmgw keys --store /var/lib/llmgw/llmgw.db +sudo -u llmgw llmgw --store /var/lib/llmgw/llmgw.db ``` -The `--store` flag is repeated in every example below. Set it once in a shell -alias if you do this often; it deliberately has no server-side default beyond -`/var/lib/llmgw/llmgw.db`. +## Self-service -## 1. Decide where the user belongs +Turn on the form: + +```yaml +auth: + enabled: true + store: /var/lib/llmgw/llmgw.db + signup_open: true +``` + +The landing page then links to it, and the rest is: + +``` +llmgw users list --pending --store … +ID EMAIL NAME STATE PROJECT SIGNED UP +usr-k4mq… ayse@example.com Ayşe pending - 4m12s ago + +llmgw users approve usr-k4mq… --project default --by ilker --store … +``` + +That is the whole operator loop. From then on they sign in at `/login`, and +`/profile` is where they create keys, see when each was last used, and revoke the +ones they are done with. A key is shown once on the page that creates it and +never again. + +**This gateway sends no email.** There is no "your account was approved" message +and no password reset, so tell people what to expect: they sign up, they tell +you, you approve, they sign in. If that loop is too manual for your size, it is +the thing to automate — not the approval step. + +To close an account: + +``` +llmgw users suspend usr-k4mq… --store … +``` + +which closes their sessions immediately. Their **API keys keep working** — those +are separate credentials, and revoking them is a separate step: + +``` +llmgw keys list --project default --store … +llmgw keys revoke key-… --store … +``` + +That separation is deliberate: a suspended person's running jobs should not all +fail at once without someone deciding they should. + +## Operator-issued + +With `signup_open: false` there is no form, and you issue keys yourself. This is +also the path for things that are not people — CI, a notebook server, a script. + +### Decide where the key belongs Keys hang off a project, projects off a team, teams off an organization. Limits can be set at any of those levels and compose downward by taking the **most @@ -33,7 +96,7 @@ live in whatever provisions the machine. Split it when you want separate limits or separate reporting, not before. -## 2. Issue the key as a one-time link +### Issue the key as a one-time link ``` llmgw keys create --store … \ @@ -67,7 +130,7 @@ provisioning something that is not a person: llmgw keys create --store … --project ci "github-runner" > /run/secrets/llmgw-key ``` -## 3. Set limits +## Limits Limits are optional. A key with none is unlimited, which is the right default on a private cluster and the wrong one the moment the gateway faces anything else. @@ -105,7 +168,7 @@ llmgw keys limits --store … --scope project --id default \ --budget 50000000 --period month # 50 units a month ``` -## 4. Point people at the right place +## The landing page Someone who reaches the gateway without a key sees the landing page. Tell it who to send them to, or it can only shrug: @@ -121,7 +184,7 @@ shown as text. Other schemes are never linked — configuration is not always written by whoever runs the gateway, and a `javascript:` href on a page served to every visitor is a short path to a stored XSS. -## 5. What the user does +## What the user sees They open the link, press the button, and get the key once. The page shows them the base URL and a working `curl`, and points at two endpoints: @@ -132,7 +195,7 @@ the base URL and a working `curl`, and points at two endpoints: `/me` is there so nobody has to ask you "why was I rate limited" or "how much have I used". It is outside `/v1` because it is not part of the OpenAI API. -## 6. Afterwards +## Rotation and revocation ``` llmgw keys list --store … --project default @@ -157,10 +220,12 @@ need revocation to bite faster. ## What this does not do -- **No self-service.** Every key needs someone with a shell here. That is - deliberate: an endpoint that mints credentials needs a login system, and this - project does not have one. If you outgrow it, that is the thing to build, not a - password field on the dashboard. -- **No SSO.** Same reason. +- **No email.** No approval notice, no password reset, no address verification. + Someone who forgets their password needs you, and there is no command for that + yet — `llmgw users create` is the workaround, which changes the address. +- **No SSO.** Accounts are local, with local passwords. +- **No roles.** Every approved account can do the same things: make its own keys + and nothing else. Administration is the CLI, and having a shell here is what + makes someone an administrator. - **Rate limits are per gateway instance.** With two instances each counts its own, so the effective limit doubles. v1 is single-instance by design. diff --git a/internal/account/account_test.go b/internal/account/account_test.go new file mode 100644 index 0000000..8f52c75 --- /dev/null +++ b/internal/account/account_test.go @@ -0,0 +1,312 @@ +package account + +import ( + "context" + "errors" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func newStore(t *testing.T) *store.SQLite { + t.Helper() + s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "a.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + ctx := context.Background() + for _, err := range []error{ + s.PutOrg(ctx, store.Org{ID: "org", Name: "Org"}), + s.PutTeam(ctx, store.Team{ID: "team", OrgID: "org", Name: "Team"}), + s.PutProject(ctx, store.Project{ID: "proj", TeamID: "team", Name: "Proj"}), + } { + if err != nil { + t.Fatal(err) + } + } + return s +} + +func newService(t *testing.T) (*Service, *store.SQLite) { + t.Helper() + s := newStore(t) + return New(s, DefaultConfig(), slog.New(slog.DiscardHandler)), s +} + +const goodPassword = "correct horse battery staple" + +func TestSignupLeavesAnAccountWaiting(t *testing.T) { + // A public signup form that produced a working account would be a way in. + svc, st := newService(t) + ctx := context.Background() + + if err := svc.SignUp(ctx, "Ayse@Example.COM", "Ayşe", goodPassword); err != nil { + t.Fatal(err) + } + u, err := st.UserByEmail(ctx, "ayse@example.com") + if err != nil { + t.Fatal(err) + } + if u.State != store.UserPending { + t.Errorf("a new account is %q", u.State) + } + if u.Email != "ayse@example.com" { + t.Errorf("the address was not normalized: %q", u.Email) + } + + if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrNotApproved) { + t.Errorf("a pending account signed in: %v", err) + } +} + +func TestSignupDoesNotRevealExistingAccounts(t *testing.T) { + // A form that says "this address already has an account" is an account + // enumerator anyone can run, and the person signing up learns nothing they + // need: either way they wait for approval. + svc, _ := newService(t) + ctx := context.Background() + + if err := svc.SignUp(ctx, "ayse@example.com", "A", goodPassword); err != nil { + t.Fatal(err) + } + if err := svc.SignUp(ctx, "ayse@example.com", "Someone Else", goodPassword); err != nil { + t.Errorf("the second signup reported %v, which confirms the address is taken", err) + } +} + +func TestApprovalIsWhatMakesAnAccountWork(t *testing.T) { + svc, st := newService(t) + ctx := context.Background() + must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword)) + + u, _ := st.UserByEmail(ctx, "ayse@example.com") + must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now())) + + token, csrf, err := svc.SignIn(ctx, "ayse@example.com", goodPassword) + if err != nil { + t.Fatal(err) + } + if token == "" || csrf == "" { + t.Fatal("sign-in produced no session") + } + _, got, err := svc.Resolve(ctx, token) + if err != nil { + t.Fatal(err) + } + if got.ProjectID != "proj" { + t.Errorf("the approved account landed in project %q", got.ProjectID) + } +} + +func TestApprovalNeedsAProject(t *testing.T) { + // An approved account with nowhere to put its keys passes every check and + // then fails at the first key it tries to make. + _, st := newStoreAndUser(t) + if err := st.ApproveUser(context.Background(), "usr-1", "", "ilker", time.Now()); err == nil { + t.Error("an account was approved into no project") + } +} + +func newStoreAndUser(t *testing.T) (*Service, *store.SQLite) { + t.Helper() + svc, st := newService(t) + hash, _ := HashPassword(goodPassword) + must(t, st.CreateUser(context.Background(), store.User{ + ID: "usr-1", Email: "ayse@example.com", Name: "A", + PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(), + })) + return svc, st +} + +func TestSuspensionTakesEffectOnOpenSessions(t *testing.T) { + // Leaving existing sessions open would make suspension advisory. + svc, st := newStoreAndUser(t) + ctx := context.Background() + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + token, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword) + if err != nil { + t.Fatal(err) + } + if _, _, err := svc.Resolve(ctx, token); err != nil { + t.Fatal(err) + } + + must(t, st.SetUserState(ctx, "usr-1", store.UserSuspended)) + if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) { + t.Errorf("a suspended account kept its session: %v", err) + } +} + +func TestSignOutEndsTheSession(t *testing.T) { + svc, st := newStoreAndUser(t) + ctx := context.Background() + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + token, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword) + must(t, svc.SignOut(ctx, token)) + if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) { + t.Errorf("the session survived sign-out: %v", err) + } +} + +func TestAWrongPasswordAndAMissingAccountAreIndistinguishable(t *testing.T) { + svc, st := newStoreAndUser(t) + ctx := context.Background() + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + _, _, wrong := svc.SignIn(ctx, "ayse@example.com", "wrong password here") + _, _, missing := svc.SignIn(ctx, "nobody@example.com", "wrong password here") + if !errors.Is(wrong, ErrBadCredentials) || !errors.Is(missing, ErrBadCredentials) { + t.Fatalf("wrong=%v missing=%v", wrong, missing) + } +} + +func TestGuessingIsBounded(t *testing.T) { + // Without this a public login form is an open brute-force target. + svc, st := newStoreAndUser(t) + ctx := context.Background() + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + cfg := DefaultConfig() + cfg.MaxAttempts = 3 + svc = New(st, cfg, slog.New(slog.DiscardHandler)) + + for i := range 3 { + if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("attempt %d: %v", i, err) + } + } + if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) { + t.Errorf("guessing was not bounded: %v", err) + } + // And a correct password is refused too while the window is open, or the + // limit would be trivially bypassed by interleaving. + if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrTooManyTries) { + t.Errorf("the window did not apply to a correct password: %v", err) + } +} + +func TestASuccessfulSignInClearsTheWindow(t *testing.T) { + // Otherwise a person who mistypes their password a few times is locked out + // for the rest of the window even after getting it right. + st := newStore(t) + hash, _ := HashPassword(goodPassword) + ctx := context.Background() + must(t, st.CreateUser(ctx, store.User{ + ID: "usr-1", Email: "a@example.com", Name: "A", + PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(), + })) + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + cfg := DefaultConfig() + cfg.MaxAttempts = 3 + svc := New(st, cfg, slog.New(slog.DiscardHandler)) + + for range 2 { + _, _, _ = svc.SignIn(ctx, "a@example.com", "nope") + } + if _, _, err := svc.SignIn(ctx, "a@example.com", goodPassword); err != nil { + t.Fatal(err) + } + for range 3 { + if _, _, err := svc.SignIn(ctx, "a@example.com", "nope"); errors.Is(err, ErrTooManyTries) { + t.Fatal("the window was not cleared by a successful sign-in") + } + } +} + +func TestCSRFTokensAreCheckedAgainstTheSession(t *testing.T) { + svc, st := newStoreAndUser(t) + ctx := context.Background() + must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now())) + + token, csrf, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword) + sess, _, _ := svc.Resolve(ctx, token) + + if !svc.CheckCSRF(sess, csrf) { + t.Error("the session's own token was rejected") + } + for _, bad := range []string{"", "wrong", csrf + "x", csrf[:len(csrf)-1]} { + if svc.CheckCSRF(sess, bad) { + t.Errorf("%q was accepted as a csrf token", bad) + } + } + // A token from another session must not work in this one. + other, otherCSRF, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword) + _ = other + if svc.CheckCSRF(sess, otherCSRF) { + t.Error("another session's token was accepted") + } +} + +func TestPasswordsAreHashedNotStored(t *testing.T) { + svc, st := newService(t) + ctx := context.Background() + must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword)) + + u, _ := st.UserByEmail(ctx, "ayse@example.com") + if strings.Contains(u.PasswordHash, goodPassword) { + t.Fatal("the password is in the database") + } + if !strings.HasPrefix(u.PasswordHash, "$argon2id$") { + t.Errorf("hash format: %q", u.PasswordHash) + } + ok, err := VerifyPassword(goodPassword, u.PasswordHash) + if err != nil || !ok { + t.Errorf("the hash does not verify: %v %v", ok, err) + } + if ok, _ := VerifyPassword("something else entirely", u.PasswordHash); ok { + t.Error("a wrong password verified") + } +} + +func TestTwoHashesOfOnePasswordDiffer(t *testing.T) { + // A salt that did not vary would make the table a rainbow-table lookup. + a, _ := HashPassword(goodPassword) + b, _ := HashPassword(goodPassword) + if a == b { + t.Fatal("hashing is not salted") + } +} + +func TestShortPasswordsAreRefused(t *testing.T) { + if _, err := HashPassword("short"); !errors.Is(err, ErrWeakPassword) { + t.Errorf("a five-character password was accepted: %v", err) + } + svc, _ := newService(t) + if err := svc.SignUp(context.Background(), "a@example.com", "A", "short"); !errors.Is(err, ErrWeakPassword) { + t.Errorf("signup accepted it: %v", err) + } +} + +func TestMalformedHashesAreRejectedRatherThanMatched(t *testing.T) { + for _, bad := range []string{"", "plaintext", "$argon2id$", "$bcrypt$v=19$m=1,t=1,p=1$aa$bb"} { + if ok, err := VerifyPassword(goodPassword, bad); ok || err == nil { + t.Errorf("VerifyPassword(%q) = %v, %v", bad, ok, err) + } + } +} + +func TestObviousNonAddressesAreRefused(t *testing.T) { + svc, _ := newService(t) + ctx := context.Background() + for _, bad := range []string{"", "ayse", "ayse@", "@example.com", "a b@example.com", "a@b", "a@@b.com"} { + if err := svc.SignUp(ctx, bad, "A", goodPassword); !errors.Is(err, ErrInvalidEmail) { + t.Errorf("SignUp(%q) = %v", bad, err) + } + } +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/account/password.go b/internal/account/password.go new file mode 100644 index 0000000..e6e0c17 --- /dev/null +++ b/internal/account/password.go @@ -0,0 +1,112 @@ +// Package account is the human half of authentication: people sign up, an +// operator approves them, and they issue their own API keys from a profile page +// (plan §7.9). +// +// This is deliberately a different mechanism from the one in internal/auth. +// That one authenticates machines holding 256-bit random keys; this one +// authenticates people holding passwords they chose. The two have opposite +// requirements — a fast hash is correct there and wrong here — and keeping them +// apart is what stops one set of assumptions leaking into the other. +package account + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2 parameters. OWASP's current floor for argon2id, which costs roughly +// 50ms per attempt on ordinary hardware — slow enough to make an offline +// dictionary attack expensive, fast enough that a login does not feel broken. +// +// They are stored with each hash, so raising them later leaves existing +// passwords working until their owners next sign in. +const ( + argonTime = 3 + argonMemory = 64 * 1024 // KiB + argonThreads = 4 + argonKeyLen = 32 + saltLen = 16 +) + +// MinPasswordLen is the only password rule. +// +// Length is the one requirement that reliably buys entropy. Composition rules — +// a digit, a symbol, a capital — push people towards Password1! and produce +// less entropy than they cost, so there are none. +const MinPasswordLen = 12 + +// ErrWeakPassword says a password is too short to accept. +var ErrWeakPassword = fmt.Errorf("a password must be at least %d characters", MinPasswordLen) + +// ErrBadHash says a stored hash could not be parsed. +var ErrBadHash = errors.New("unreadable password hash") + +// HashPassword derives a storable hash. +func HashPassword(password string) (string, error) { + if len([]rune(password)) < MinPasswordLen { + return "", ErrWeakPassword + } + salt := make([]byte, saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + + b64 := base64.RawStdEncoding.EncodeToString + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(sum)), nil +} + +// VerifyPassword reports whether a password matches a stored hash. +// +// The comparison is constant time. The parameters come from the hash rather +// than from the constants above, so a hash written under older settings still +// verifies. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, ErrBadHash + } + + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { + return false, ErrBadHash + } + var memory, time uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return false, ErrBadHash + } + + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, ErrBadHash + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, ErrBadHash + } + + got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} + +// dummyHash is verified against when no account matches, so a login attempt for +// an address that does not exist costs the same time as one that does. +// +// Without it, a stopwatch enumerates every account on the system. +var dummyHash string + +func init() { + h, err := HashPassword(strings.Repeat("x", MinPasswordLen)) + if err != nil { + panic("account: cannot build the dummy hash: " + err.Error()) + } + dummyHash = h +} diff --git a/internal/account/service.go b/internal/account/service.go new file mode 100644 index 0000000..0a8da42 --- /dev/null +++ b/internal/account/service.go @@ -0,0 +1,266 @@ +package account + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base32" + "errors" + "log/slog" + "strings" + "sync" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// What a sign-in or sign-up can fail with. +var ( + ErrBadCredentials = errors.New("that email and password do not match an account") + ErrNotApproved = errors.New("this account is waiting for approval") + ErrSuspended = errors.New("this account has been suspended") + ErrTooManyTries = errors.New("too many attempts; try again shortly") + ErrInvalidEmail = errors.New("that does not look like an email address") +) + +// Store is what the service needs from persistence. +type Store interface { + CreateUser(ctx context.Context, u store.User) error + UserByEmail(ctx context.Context, email string) (store.User, error) + UserByID(ctx context.Context, id string) (store.User, error) + TouchLogin(ctx context.Context, id string, at time.Time) error + + CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error + SessionByToken(ctx context.Context, token string) (store.Session, store.User, error) + DeleteSession(ctx context.Context, token string) error + TouchSession(ctx context.Context, token string, at time.Time) error +} + +// Config tunes the service. +type Config struct { + // SignupOpen decides whether anyone may create an account. Off, the form is + // not served at all; accounts are made with `llmgw users create`. + SignupOpen bool + // SessionTTL is how long a sign-in lasts. + SessionTTL time.Duration + // MaxAttempts and AttemptWindow bound password guessing per address. + MaxAttempts int + AttemptWindow time.Duration +} + +// DefaultConfig returns sensible values. +func DefaultConfig() Config { + return Config{ + SignupOpen: true, + SessionTTL: store.SessionTTL, + MaxAttempts: 10, + AttemptWindow: 15 * time.Minute, + } +} + +func (c *Config) normalize() { + d := DefaultConfig() + if c.SessionTTL <= 0 { + c.SessionTTL = d.SessionTTL + } + if c.MaxAttempts <= 0 { + c.MaxAttempts = d.MaxAttempts + } + if c.AttemptWindow <= 0 { + c.AttemptWindow = d.AttemptWindow + } +} + +// Service is signup, sign-in and sessions. +type Service struct { + store Store + cfg Config + log *slog.Logger + now func() time.Time + + mu sync.Mutex + attempts map[string]*attemptWindow +} + +type attemptWindow struct { + count int + since time.Time +} + +// New builds the service. +func New(s Store, cfg Config, log *slog.Logger) *Service { + cfg.normalize() + if log == nil { + log = slog.Default() + } + return &Service{store: s, cfg: cfg, log: log, now: time.Now, attempts: map[string]*attemptWindow{}} +} + +// SignupOpen reports whether the form should be served. +func (s *Service) SignupOpen() bool { return s.cfg.SignupOpen } + +// SignUp creates a pending account. +// +// It returns nil whether or not the address was already taken. A signup form +// that says "this address already has an account" is an account enumerator that +// anyone can run, and the person signing up learns nothing they need: either +// way, what happens next is that they wait for approval. +func (s *Service) SignUp(ctx context.Context, email, name, password string) error { + email = store.NormalizeEmail(email) + if !validEmail(email) { + return ErrInvalidEmail + } + hash, err := HashPassword(password) + if err != nil { + return err + } + if name = strings.TrimSpace(name); name == "" { + name = email + } + + err = s.store.CreateUser(ctx, store.User{ + ID: randomID("usr"), Email: email, Name: name, + PasswordHash: hash, State: store.UserPending, CreatedAt: s.now(), + }) + if errors.Is(err, store.ErrEmailTaken) { + // Logged so an operator can see it; not told to the caller. + s.log.Info("signup for an address that already has an account") + return nil + } + if err != nil { + return err + } + s.log.Info("a new account is waiting for approval", "email", email) + return nil +} + +// SignIn checks a password and opens a session, returning the session token and +// the CSRF token that must accompany the forms in it. +func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) { + email = store.NormalizeEmail(email) + if !s.allowAttempt(email) { + return "", "", ErrTooManyTries + } + + u, err := s.store.UserByEmail(ctx, email) + if errors.Is(err, store.ErrNotFound) { + // Verify anyway, against a hash that cannot match. Returning early here + // would let a stopwatch enumerate every account on the system. + _, _ = VerifyPassword(password, dummyHash) + return "", "", ErrBadCredentials + } + if err != nil { + return "", "", err + } + + ok, err := VerifyPassword(password, u.PasswordHash) + if err != nil || !ok { + return "", "", ErrBadCredentials + } + // The password was right, so this was not an attack. + s.clearAttempts(email) + + // State is checked after the password, on purpose. Checking first would + // tell anyone who guesses an address whether it has a pending account. + switch u.State { + case store.UserPending: + return "", "", ErrNotApproved + case store.UserSuspended: + return "", "", ErrSuspended + } + + if token, err = store.NewSessionToken(); err != nil { + return "", "", err + } + if csrf, err = store.NewSessionToken(); err != nil { + return "", "", err + } + if err := s.store.CreateSession(ctx, u.ID, token, csrf, s.cfg.SessionTTL); err != nil { + return "", "", err + } + if err := s.store.TouchLogin(ctx, u.ID, s.now()); err != nil { + s.log.Warn("could not record a sign-in", "err", err) + } + return token, csrf, nil +} + +// Resolve turns a session cookie into the person holding it. +func (s *Service) Resolve(ctx context.Context, token string) (store.Session, store.User, error) { + if token == "" { + return store.Session{}, store.User{}, store.ErrNoSession + } + sess, u, err := s.store.SessionByToken(ctx, token) + if err != nil { + return store.Session{}, store.User{}, err + } + if err := s.store.TouchSession(ctx, token, s.now()); err != nil { + s.log.Debug("could not touch a session", "err", err) + } + return sess, u, nil +} + +// SignOut closes one session. +func (s *Service) SignOut(ctx context.Context, token string) error { + if token == "" { + return nil + } + return s.store.DeleteSession(ctx, token) +} + +// CheckCSRF compares a submitted token against the session's. +// +// A cookie authenticates the browser, not the page that asked. Without this, any +// site the person visits can post a form to ours and have the browser attach the +// session automatically. +func (s *Service) CheckCSRF(sess store.Session, submitted string) bool { + if submitted == "" || len(sess.CSRFHash) == 0 { + return false + } + return subtle.ConstantTimeCompare(store.HashKey(submitted), sess.CSRFHash) == 1 +} + +// allowAttempt bounds password guessing per address. +// +// Per address rather than per IP: an attacker has many addresses to come from +// and only one to guess against, and limiting by IP would lock out everyone +// behind one office NAT the moment somebody fat-fingers their password. +func (s *Service) allowAttempt(email string) bool { + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + + w, ok := s.attempts[email] + if !ok || now.Sub(w.since) > s.cfg.AttemptWindow { + s.attempts[email] = &attemptWindow{count: 1, since: now} + return true + } + w.count++ + return w.count <= s.cfg.MaxAttempts +} + +func (s *Service) clearAttempts(email string) { + s.mu.Lock() + delete(s.attempts, email) + s.mu.Unlock() +} + +// validEmail is deliberately crude. A real check is delivery, which this does +// not do; this only rejects what is obviously not an address so the database +// does not fill with them. +func validEmail(s string) bool { + at := strings.IndexByte(s, '@') + if at <= 0 || at != strings.LastIndexByte(s, '@') || at == len(s)-1 { + return false + } + if strings.ContainsAny(s, " \t\r\n<>\"") { + return false + } + return strings.Contains(s[at+1:], ".") +} + +func randomID(prefix string) string { + var buf [10]byte + _, _ = rand.Read(buf[:]) + return prefix + "-" + strings.ToLower( + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:])) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index fc0e278..9718c99 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,6 +41,7 @@ func Root() *cobra.Command { newAgentCmd(), newCtlCmd(), newTenancyCmd(), + newUsersCmd(), newSimEngineCmd(), ) return root diff --git a/internal/cli/users.go b/internal/cli/users.go new file mode 100644 index 0000000..e958324 --- /dev/null +++ b/internal/cli/users.go @@ -0,0 +1,186 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "gitea.mynodes.xyz/ilker/llmgw/internal/account" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func newUsersCmd() *cobra.Command { + var dbPath string + + cmd := &cobra.Command{ + Use: "users", + Short: "Approve and manage the people who sign up", + Long: "Signing up creates an account that cannot do anything. Approval is what\n" + + "makes it work, and it happens here rather than in a browser: approving is\n" + + "the step that decides who gets to spend the cluster, and it needs a shell\n" + + "on this machine rather than a session cookie.", + } + cmd.PersistentFlags().StringVar(&dbPath, "store", "/var/lib/llmgw/llmgw.db", "path to the store") + open := func(cmd *cobra.Command) (*store.SQLite, error) { + return store.OpenSQLite(cmd.Context(), dbPath) + } + + cmd.AddCommand(newUserListCmd(open), newUserApproveCmd(open), + newUserStateCmd(open, "suspend", store.UserSuspended, + "Stop an account working, keeping it and its history"), + newUserStateCmd(open, "reinstate", store.UserActive, + "Let a suspended account work again"), + newUserCreateCmd(open)) + return cmd +} + +type userOpener func(*cobra.Command) (*store.SQLite, error) + +func newUserListCmd(open userOpener) *cobra.Command { + var pending bool + cmd := &cobra.Command{ + Use: "list", + Short: "List accounts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + + state := store.UserState("") + if pending { + state = store.UserPending + } + users, err := s.Users(cmd.Context(), state) + if err != nil { + return err + } + if len(users) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no accounts") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tEMAIL\tNAME\tSTATE\tPROJECT\tSIGNED UP\tLAST LOGIN") + now := time.Now() + for _, u := range users { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + u.ID, u.Email, u.Name, u.State, dash(u.ProjectID), + ago(u.CreatedAt, now), ago(u.LastLoginAt, now)) + } + return w.Flush() + }, + } + cmd.Flags().BoolVar(&pending, "pending", false, "only accounts waiting for approval") + return cmd +} + +func newUserApproveCmd(open userOpener) *cobra.Command { + var project, by string + cmd := &cobra.Command{ + Use: "approve ", + Short: "Let an account sign in and issue its own keys", + Long: "The project is required: it decides which limits this person's keys\n" + + "inherit. An account approved into no project would pass every check and\n" + + "then fail at the first key it tried to make.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + + if err := s.ApproveUser(cmd.Context(), args[0], project, by, time.Now()); err != nil { + return fmt.Errorf("approving %s: %w", args[0], err) + } + u, err := s.UserByID(cmd.Context(), args[0]) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s approved into project %s\n", u.Email, project) + cmd.PrintErrln("They can sign in and create their own keys from /profile.") + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to") + cmd.Flags().StringVar(&by, "by", "", "who approved, for the record") + _ = cmd.MarkFlagRequired("project") + return cmd +} + +func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command { + return &cobra.Command{ + Use: verb + " ", + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + + if err := s.SetUserState(cmd.Context(), args[0], state); err != nil { + return err + } + if state == store.UserSuspended { + // Otherwise the person stays signed in and suspension is + // advisory until their session happens to expire. + if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil { + return err + } + cmd.PrintErrln("Their sessions were closed. Their API keys still work — revoke those separately.") + } + fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", args[0], state) + return nil + }, + } +} + +func newUserCreateCmd(open userOpener) *cobra.Command { + var project, password string + cmd := &cobra.Command{ + Use: "create ", + Short: "Create an account directly, already approved", + Long: "For when signup is closed, or for the first account on a new deployment.\n" + + "The password is set here and should be changed by its owner.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if password == "" { + return fmt.Errorf("--password is required") + } + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + + hash, err := account.HashPassword(password) + if err != nil { + return err + } + id := randomID("usr") + u := store.User{ + ID: id, Email: args[0], Name: args[0], + PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(), + } + if err := s.CreateUser(cmd.Context(), u); err != nil { + return err + } + if err := s.ApproveUser(cmd.Context(), id, project, "cli", time.Now()); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id) + cmd.PrintErrf("%s created and approved into project %s\n", args[0], project) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to") + cmd.Flags().StringVar(&password, "password", "", "initial password") + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go index 5c11067..e7ccb17 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -83,6 +83,11 @@ type Auth struct { // client looping with a bad key cannot become a database read per request. NegativeTTL time.Duration `koanf:"negative_ttl"` + // SignupOpen serves the signup form. Accounts it creates are pending until + // an operator approves them, so this is not a way in — but turning it off + // means accounts are made with `llmgw users create` instead. + SignupOpen bool `koanf:"signup_open"` + // Pricing is what each model costs, for budgets. A model absent from the // table costs whatever DefaultPrice says, which is nothing unless set. Pricing map[string]Price `koanf:"pricing"` diff --git a/internal/router/handlers.go b/internal/router/handlers.go index fc3dff8..e20e137 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -89,6 +89,7 @@ func (r *Router) Handler() http.Handler { // it is safe to serve without a key — and it has to be, because the person // reading it is the one who does not have one yet. mux.HandleFunc("GET /", web.Landing(version.Version, r.auth != nil, + r.accounts != nil && r.accounts.SignupOpen(), r.landing.Contact, r.landing.Notice)) mux.Handle("GET /_static/", web.Static()) mux.HandleFunc("GET /me", r.handleMe) @@ -101,6 +102,12 @@ func (r *Router) Handler() http.Handler { mux.HandleFunc("GET /claim/{token}", claim) mux.HandleFunc("POST /claim/{token}", claim) } + if r.accounts != nil { + // Signup, sign-in and the profile page. These are browser surfaces + // authenticated by a session cookie, not by an API key, which is why + // they are exempt below. + web.NewAccountPages(r.accounts, r.store, version.Version, r.log).Register(mux) + } if r.auth == nil { return mux @@ -108,7 +115,9 @@ func (r *Router) Handler() http.Handler { // Wrapping the whole mux rather than checking inside each handler: adding a // route must not be able to add an unauthenticated one by omission. Health // stays open because a load balancer has no key. - return auth.Middleware(r.auth, r.log, "/health", "/", "/_static/", "/claim/")(mux) + return auth.Middleware(r.auth, r.log, + "/health", "/", "/_static/", "/claim/", + "/signup", "/login", "/logout", "/profile", "/profile/")(mux) } // parsedRequest is what the router needs from a request. Everything else in the diff --git a/internal/router/router.go b/internal/router/router.go index e18204a..93d5028 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/account" "gitea.mynodes.xyz/ilker/llmgw/internal/accounting" "gitea.mynodes.xyz/ilker/llmgw/internal/auth" "gitea.mynodes.xyz/ilker/llmgw/internal/config" @@ -49,8 +50,10 @@ type Router struct { // otherwise care about. landing config.Landing - // auth, store, acct and recorder are nil when authentication is disabled. + // auth, store, acct, recorder and accounts are nil when authentication is + // disabled. auth *auth.Authenticator + accounts *account.Service store *storepkg.SQLite acct *accounting.Accountant recorder *accounting.Recorder @@ -144,6 +147,10 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) { BatchSize: cfg.Auth.BatchSize, Capacity: cfg.Auth.BufferSize, }, log) + r.accounts = account.New(st, account.Config{ + SignupOpen: cfg.Auth.SignupOpen, + }, log) + r.acct = accounting.New( accounting.NewPricing(prices, accounting.Price{ PromptPer1k: cfg.Auth.DefaultPrice.PromptPer1k, diff --git a/internal/store/migrations/sqlite/0003_users.sql b/internal/store/migrations/sqlite/0003_users.sql new file mode 100644 index 0000000..d05692a --- /dev/null +++ b/internal/store/migrations/sqlite/0003_users.sql @@ -0,0 +1,47 @@ +-- Accounts, so a person can sign up, be approved, and then issue their own keys +-- without an operator running a command for each one. +CREATE TABLE users ( + id TEXT PRIMARY KEY, + -- Lowercased at the boundary. Two accounts differing only in case would be + -- two accounts to the database and one to every human who looks at them. + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + -- argon2id, encoded with its own parameters so the cost can be raised later + -- without invalidating existing hashes. Slow hashing is right here and wrong + -- for API keys: this input is chosen by a human and has a dictionary. + password_hash TEXT NOT NULL, + -- 'pending' until an operator approves, then 'active'. 'suspended' keeps the + -- account and stops it working. + state TEXT NOT NULL, + -- Assigned at approval: which project this person's keys belong to, and + -- therefore which limits they inherit. + project_id TEXT REFERENCES projects(id), + created_at INTEGER NOT NULL, + approved_at INTEGER, + approved_by TEXT, + last_login_at INTEGER +); +CREATE INDEX users_by_state ON users(state); + +-- Server-side sessions rather than signed cookies, so signing out and +-- suspending an account take effect immediately instead of whenever a token +-- happens to expire. +CREATE TABLE sessions ( + token_hash BLOB PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + -- Per session, submitted with every form. A cookie alone authenticates the + -- browser, not the page that asked, which is what makes a cross-site POST + -- work. + csrf_hash BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); +CREATE INDEX sessions_by_user ON sessions(user_id); +CREATE INDEX sessions_by_expiry ON sessions(expires_at); + +-- A key issued through the profile page belongs to the person who made it, so +-- they can see and revoke their own and nobody else's. NULL means a key an +-- operator issued directly. +ALTER TABLE api_keys ADD COLUMN user_id TEXT REFERENCES users(id); +CREATE INDEX api_keys_by_user ON api_keys(user_id); diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..d77ae02 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "time" +) + +// SessionTTL is how long a sign-in lasts. +const SessionTTL = 14 * 24 * time.Hour + +// ErrNoSession says a session cookie names nothing usable. +var ErrNoSession = errors.New("not signed in") + +// Session is an open sign-in. +type Session struct { + UserID string + CSRFHash []byte + ExpiresAt time.Time +} + +// NewSessionToken mints a session or CSRF token. +func NewSessionToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return encodeBase62(raw), nil +} + +// CreateSession opens a session and returns nothing: the caller already holds +// the tokens, and neither is stored in the clear. +func (s *SQLite) CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error { + if ttl <= 0 { + ttl = SessionTTL + } + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO sessions (token_hash, user_id, csrf_hash, created_at, expires_at, last_seen) + VALUES (?, ?, ?, ?, ?, ?)`, + HashKey(token), userID, HashKey(csrf), millis(now), millis(now.Add(ttl)), millis(now)) + return err +} + +// SessionByToken resolves a cookie to its session and the person it belongs to. +// +// One query rather than two: a session whose user has been suspended must stop +// working immediately, and checking that in a second round trip is a check +// somebody eventually forgets. +func (s *SQLite) SessionByToken(ctx context.Context, token string) (Session, User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT s.user_id, s.csrf_hash, s.expires_at, + u.id, u.email, u.name, u.password_hash, u.state, COALESCE(u.project_id, ''), + u.created_at, u.approved_at, u.approved_by, u.last_login_at + FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = ?`, HashKey(token)) + + var sess Session + var expires int64 + var u User + var state string + var created int64 + var approved, lastLogin sql.NullInt64 + var approvedBy sql.NullString + + if err := row.Scan(&sess.UserID, &sess.CSRFHash, &expires, + &u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID, + &created, &approved, &approvedBy, &lastLogin); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Session{}, User{}, ErrNoSession + } + return Session{}, User{}, err + } + + sess.ExpiresAt = fromMillis(expires) + if time.Now().After(sess.ExpiresAt) { + return Session{}, User{}, ErrNoSession + } + + u.State = UserState(state) + u.CreatedAt = fromMillis(created) + u.ApprovedAt = fromNullMillis(approved) + u.ApprovedBy = approvedBy.String + u.LastLoginAt = fromNullMillis(lastLogin) + + if !u.CanSignIn() { + // Suspended between sign-in and now. The session exists; it just stops + // meaning anything. + return Session{}, User{}, ErrNoSession + } + return sess, u, nil +} + +// TouchSession records activity, at most once a minute. +func (s *SQLite) TouchSession(ctx context.Context, token string, at time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE sessions SET last_seen = ? WHERE token_hash = ? AND last_seen < ?`, + millis(at), HashKey(token), millis(at.Add(-time.Minute))) + return err +} + +// DeleteSession signs one browser out. +func (s *SQLite) DeleteSession(ctx context.Context, token string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, HashKey(token)) + return err +} + +// DeleteUserSessions signs a person out everywhere. +// +// What suspension and a password change both need: leaving old sessions open +// would make either of them advisory. +func (s *SQLite) DeleteUserSessions(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +// PurgeSessions removes expired rows. +func (s *SQLite) PurgeSessions(ctx context.Context, before time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, millis(before)) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return n, nil +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..fc014c6 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,199 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +// UserState is where an account is in its life. +type UserState string + +// The account states. A new account is pending until an operator approves it, +// which is what keeps a public signup form from being a way in. +const ( + UserPending UserState = "pending" + UserActive UserState = "active" + UserSuspended UserState = "suspended" +) + +// ErrEmailTaken says an address already has an account. +// +// It is returned to callers that need to know — the CLI — and deliberately not +// surfaced to a signup form, where it would confirm which addresses have +// accounts here. +var ErrEmailTaken = errors.New("that address already has an account") + +// User is a person. +type User struct { + ID string + Email string + Name string + PasswordHash string + State UserState + // ProjectID is assigned at approval and decides which limits the person's + // keys inherit. Empty while pending. + ProjectID string + CreatedAt time.Time + ApprovedAt time.Time + ApprovedBy string + LastLoginAt time.Time +} + +// CanSignIn reports whether this account may open a session. +func (u User) CanSignIn() bool { return u.State == UserActive } + +// NormalizeEmail is applied at every boundary, so two accounts cannot differ +// only in case — which would be two accounts to the database and one to every +// human who looks at them. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +const userColumns = `id, email, name, password_hash, state, COALESCE(project_id, ''), + created_at, approved_at, approved_by, last_login_at` + +// CreateUser records a new, unapproved account. +func (s *SQLite) CreateUser(ctx context.Context, u User) error { + u.Email = NormalizeEmail(u.Email) + _, err := s.db.ExecContext(ctx, + `INSERT INTO users (id, email, name, password_hash, state, project_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Email, u.Name, u.PasswordHash, string(u.State), + nullString(u.ProjectID), millis(u.CreatedAt)) + if err != nil && strings.Contains(err.Error(), "UNIQUE") { + return ErrEmailTaken + } + return err +} + +// UserByEmail finds an account by its address. +func (s *SQLite) UserByEmail(ctx context.Context, email string) (User, error) { + return s.scanUser(s.db.QueryRowContext(ctx, + `SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email))) +} + +// UserByID finds an account. +func (s *SQLite) UserByID(ctx context.Context, id string) (User, error) { + return s.scanUser(s.db.QueryRowContext(ctx, + `SELECT `+userColumns+` FROM users WHERE id = ?`, id)) +} + +// Users lists accounts, optionally filtered by state. +func (s *SQLite) Users(ctx context.Context, state UserState) ([]User, error) { + query := `SELECT ` + userColumns + ` FROM users` + var args []any + if state != "" { + query += ` WHERE state = ?` + args = append(args, string(state)) + } + query += ` ORDER BY created_at` + return queryAll(ctx, s.db, query, args, func(rows *sql.Rows) (User, error) { + return s.scanUser(rows) + }) +} + +// ApproveUser activates an account and places it in a project. +// +// The project is required: an approved account with nowhere to put its keys +// would pass every check and then fail at the first key it tried to make. +func (s *SQLite) ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error { + if projectID == "" { + return errors.New("approving an account needs a project for its keys") + } + res, err := s.db.ExecContext(ctx, + `UPDATE users SET state = ?, project_id = ?, approved_at = ?, approved_by = ? + WHERE id = ? AND state = ?`, + string(UserActive), projectID, millis(at), by, id, string(UserPending)) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// SetUserState suspends or reinstates an account. +func (s *SQLite) SetUserState(ctx context.Context, id string, state UserState) error { + res, err := s.db.ExecContext(ctx, `UPDATE users SET state = ? WHERE id = ?`, string(state), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// TouchLogin records a successful sign-in. +func (s *SQLite) TouchLogin(ctx context.Context, id string, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, + millis(at), id) + return err +} + +// KeysForUser lists the keys a person made for themselves. +func (s *SQLite) KeysForUser(ctx context.Context, userID string) ([]Key, error) { + return queryAll(ctx, s.db, + `SELECT `+keyColumns+` FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, + []any{userID}, func(rows *sql.Rows) (Key, error) { return scanKey(rows) }) +} + +// CreateUserKey issues a key owned by a person. +func (s *SQLite) CreateUserKey(ctx context.Context, userID string, k Key) error { + if len(k.Hash) == 0 { + return errors.New("store: a key needs a hash") + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO api_keys (`+keyColumns+`, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + k.ID, k.Hash, k.Prefix, k.Name, k.ProjectID, + nullString(k.Band), nullString(k.RoutingProfile), + millis(k.CreatedAt), nullMillis(k.ExpiresAt), nullMillis(k.RevokedAt), + nullMillis(k.LastUsedAt), userID) + return err +} + +// RevokeUserKey revokes a key only if it belongs to this person. +// +// The ownership check is in the statement rather than in a prior read: two +// queries would leave a window where a key changes hands between them, and more +// importantly it would be one forgotten check away from letting anyone revoke +// anyone's key by id. +func (s *SQLite) RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error { + res, err := s.db.ExecContext(ctx, + `UPDATE api_keys SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL`, + millis(at), keyID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLite) scanUser(sc scanner) (User, error) { + var u User + var state string + var created int64 + var approved, lastLogin sql.NullInt64 + var approvedBy sql.NullString + + if err := sc.Scan(&u.ID, &u.Email, &u.Name, &u.PasswordHash, &state, &u.ProjectID, + &created, &approved, &approvedBy, &lastLogin); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + return User{}, err + } + u.State = UserState(state) + u.CreatedAt = fromMillis(created) + u.ApprovedAt = fromNullMillis(approved) + u.ApprovedBy = approvedBy.String + u.LastLoginAt = fromNullMillis(lastLogin) + return u, nil +} diff --git a/internal/web/account.go b/internal/web/account.go new file mode 100644 index 0000000..cdb8ab5 --- /dev/null +++ b/internal/web/account.go @@ -0,0 +1,393 @@ +package web + +import ( + "context" + "crypto/rand" + "encoding/base32" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/account" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// SessionCookie is the name of the cookie a signed-in browser carries. +const SessionCookie = "llmgw_session" + +// Accounts is what the pages need from the account service. +type Accounts interface { + SignUp(ctx context.Context, email, name, password string) error + SignIn(ctx context.Context, email, password string) (token, csrf string, err error) + Resolve(ctx context.Context, token string) (store.Session, store.User, error) + SignOut(ctx context.Context, token string) error + CheckCSRF(sess store.Session, submitted string) bool + SignupOpen() bool +} + +// KeyOwner is the store half of the profile page. +type KeyOwner interface { + KeysForUser(ctx context.Context, userID string) ([]store.Key, error) + CreateUserKey(ctx context.Context, userID string, k store.Key) error + RevokeUserKey(ctx context.Context, userID, keyID string, at time.Time) error + Resolve(ctx context.Context, keyID string) (store.Effective, error) + LimitsFor(ctx context.Context, scope store.Scope, id string) (store.Limits, error) +} + +// AccountPages serves signup, sign-in and the profile. +type AccountPages struct { + accounts Accounts + keys KeyOwner + version string + log *slog.Logger +} + +// NewAccountPages builds the handlers. +func NewAccountPages(a Accounts, k KeyOwner, version string, log *slog.Logger) *AccountPages { + if log == nil { + log = slog.Default() + } + return &AccountPages{accounts: a, keys: k, version: version, log: log} +} + +// Register mounts the pages. They are all browser surfaces and none of them +// takes an API key, so the caller must exempt these paths from key +// authentication. +func (p *AccountPages) Register(mux *http.ServeMux) { + if p.accounts.SignupOpen() { + mux.HandleFunc("GET /signup", p.signupForm) + mux.HandleFunc("POST /signup", p.signup) + } + mux.HandleFunc("GET /login", p.loginForm) + mux.HandleFunc("POST /login", p.login) + mux.HandleFunc("POST /logout", p.logout) + mux.HandleFunc("GET /profile", p.profile) + mux.HandleFunc("POST /profile/keys", p.createKey) + mux.HandleFunc("POST /profile/keys/{id}/revoke", p.revokeKey) +} + +// --- signup --- + +type signupData struct { + Email, Name, Error string + Done bool + MinPassword int + Version string +} + +func (p *AccountPages) signupForm(w http.ResponseWriter, _ *http.Request) { + noStore(w) + render(w, "signup", signupData{MinPassword: account.MinPasswordLen, Version: p.version}) +} + +func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) { + noStore(w) + d := signupData{ + Email: strings.TrimSpace(r.FormValue("email")), + Name: strings.TrimSpace(r.FormValue("name")), + MinPassword: account.MinPasswordLen, + Version: p.version, + } + + err := p.accounts.SignUp(r.Context(), d.Email, d.Name, r.FormValue("password")) + switch { + case err == nil: + // The same page whether or not the address was already taken. Anything + // else is an account enumerator that anyone can run. + d.Done = true + case errors.Is(err, account.ErrWeakPassword), errors.Is(err, account.ErrInvalidEmail): + d.Error = err.Error() + default: + p.log.Error("signup failed", "err", err) + d.Error = "something went wrong; try again" + } + render(w, "signup", d) +} + +// --- sign in and out --- + +type loginData struct { + Email, Error string + SignupOpen bool + Version string +} + +func (p *AccountPages) loginForm(w http.ResponseWriter, _ *http.Request) { + noStore(w) + render(w, "login", loginData{SignupOpen: p.accounts.SignupOpen(), Version: p.version}) +} + +func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) { + noStore(w) + email := strings.TrimSpace(r.FormValue("email")) + + token, csrf, err := p.accounts.SignIn(r.Context(), email, r.FormValue("password")) + if err != nil { + d := loginData{Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version} + switch { + case errors.Is(err, account.ErrBadCredentials), + errors.Is(err, account.ErrNotApproved), + errors.Is(err, account.ErrSuspended), + errors.Is(err, account.ErrTooManyTries): + d.Error = err.Error() + default: + p.log.Error("sign-in failed", "err", err) + d.Error = "something went wrong; try again" + } + w.WriteHeader(http.StatusUnauthorized) + render(w, "login", d) + return + } + + setSessionCookie(w, r, token) + setCSRFCookie(w, r, csrf) + http.Redirect(w, r, "/profile", http.StatusSeeOther) +} + +func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) { + noStore(w) + // Even signing out is a state change, so it needs its token: otherwise any + // page can sign a visitor out of this one. + sess, _, ok := p.session(r) + if ok && !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if c, err := r.Cookie(SessionCookie); err == nil { + if err := p.accounts.SignOut(r.Context(), c.Value); err != nil { + p.log.Warn("could not close a session", "err", err) + } + } + clearSessionCookie(w, r) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// --- profile --- + +type limitRow struct{ Name, Value string } + +type profileData struct { + User store.User + Keys []store.Key + Limits []limitRow + NewKey string + Error string + CSRF string + BaseURL string + Version string + Now string +} + +func (p *AccountPages) profile(w http.ResponseWriter, r *http.Request) { + noStore(w) + sess, user, ok := p.session(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + p.renderProfile(w, r, sess, user, "", "") +} + +func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) { + noStore(w) + sess, user, ok := p.session(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + name = "untitled" + } + secret, k, err := store.NewKey(randomKeyID(), name, user.ProjectID) + if err != nil { + p.log.Error("could not mint a key", "err", err) + p.renderProfile(w, r, sess, user, "", "something went wrong; try again") + return + } + k.CreatedAt = time.Now() + if err := p.keys.CreateUserKey(r.Context(), user.ID, k); err != nil { + p.log.Error("could not store a key", "err", err) + p.renderProfile(w, r, sess, user, "", "something went wrong; try again") + return + } + p.log.Info("a user created a key", "user", user.ID, "key", k.ID) + + // Shown on this response rather than after a redirect: a redirect would + // have to carry the key in a URL or a flash, and both put it somewhere it + // does not belong. + p.renderProfile(w, r, sess, user, secret, "") +} + +func (p *AccountPages) revokeKey(w http.ResponseWriter, r *http.Request) { + noStore(w) + sess, user, ok := p.session(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Ownership is enforced in the statement, so a guessed key id from another + // account changes nothing. + err := p.keys.RevokeUserKey(r.Context(), user.ID, r.PathValue("id"), time.Now()) + if err != nil && !errors.Is(err, store.ErrNotFound) { + p.log.Error("could not revoke a key", "err", err) + } + http.Redirect(w, r, "/profile", http.StatusSeeOther) +} + +func (p *AccountPages) renderProfile(w http.ResponseWriter, r *http.Request, + sess store.Session, user store.User, newKey, errMsg string, +) { + d := profileData{ + User: user, NewKey: newKey, Error: errMsg, + BaseURL: baseURL(r), + Version: p.version, + Now: time.Now().UTC().Format(time.RFC3339), + } + // The form token comes from the cookie, never from whatever the request + // happened to carry: echoing a submitted value back into the next form + // would let an attacker choose both halves of the comparison. + d.CSRF = csrfFor(r) + _ = sess + + keys, err := p.keys.KeysForUser(r.Context(), user.ID) + if err != nil { + p.log.Error("could not list keys", "err", err) + } + d.Keys = keys + d.Limits = p.limitsFor(r.Context(), user) + render(w, "profile", d) +} + +// limitsFor renders the limits a person's keys inherit from their project. +// +// From the project rather than from a key, because a person with no keys yet +// still wants to know what they are getting. +func (p *AccountPages) limitsFor(ctx context.Context, user store.User) []limitRow { + l, err := p.keys.LimitsFor(ctx, store.ScopeProject, user.ProjectID) + if err != nil { + return nil + } + var out []limitRow + if l.RPM != nil { + out = append(out, limitRow{"Requests per minute", itoa(*l.RPM)}) + } + if l.TPM != nil { + out = append(out, limitRow{"Tokens per minute", itoa(*l.TPM)}) + } + if l.Budget != nil { + out = append(out, limitRow{"Budget", units(*l.Budget) + " per " + string(l.BudgetPeriod)}) + } + if l.AllowedModels != nil { + if len(l.AllowedModels) == 0 { + out = append(out, limitRow{"Models", "none"}) + } else { + out = append(out, limitRow{"Models", strings.Join(l.AllowedModels, ", ")}) + } + } + return out +} + +// --- helpers --- + +func (p *AccountPages) session(r *http.Request) (store.Session, store.User, bool) { + c, err := r.Cookie(SessionCookie) + if err != nil { + return store.Session{}, store.User{}, false + } + sess, user, err := p.accounts.Resolve(r.Context(), c.Value) + if err != nil { + return store.Session{}, store.User{}, false + } + return sess, user, true +} + +// csrfFor returns the token a form in this session must carry. +// +// The session stores only a hash, so the value itself has to travel with the +// browser. It rides the same cookie jar as the session under its own name, +// which is the double-submit pattern: an attacker's page can make the browser +// send our cookies but cannot read them to put one in a form field. +func csrfFor(r *http.Request) string { + if c, err := r.Cookie(csrfCookie); err == nil { + return c.Value + } + return "" +} + +func setCSRFCookie(w http.ResponseWriter, r *http.Request, csrf string) { + // HttpOnly as well: the server renders it into forms, so no script needs to + // read it, and the attack this defends against works by making the browser + // send cookies it cannot read. + http.SetCookie(w, &http.Cookie{ + Name: csrfCookie, Value: csrf, Path: "/", + HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, + MaxAge: int(store.SessionTTL.Seconds()), + }) +} + +func randomKeyID() string { + var buf [10]byte + _, _ = rand.Read(buf[:]) + return "key-" + strings.ToLower( + base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:])) +} + +func itoa(v int) string { return strconv.Itoa(v) } + +func units(micros int64) string { + return strconv.FormatFloat(float64(micros)/1e6, 'f', 2, 64) +} + +const csrfCookie = "llmgw_csrf" + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookie, Value: token, Path: "/", + HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, + MaxAge: int(store.SessionTTL.Seconds()), + }) +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + for _, name := range []string{SessionCookie, csrfCookie} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", + HttpOnly: name == SessionCookie, Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, MaxAge: -1, + }) + } +} + +// isHTTPS decides whether to mark cookies Secure. +// +// Behind a reverse proxy this process sees plain HTTP, so the forwarded header +// is the only thing that knows. Getting it wrong in the safe direction — marking +// Secure when the connection is plain — would break sign-in over http://, which +// is why it is not simply hardcoded. +func isHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(firstValue(r.Header.Get("X-Forwarded-Proto")), "https") +} + +func noStore(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") +} diff --git a/internal/web/landing.go b/internal/web/landing.go index a446c23..e44b52e 100644 --- a/internal/web/landing.go +++ b/internal/web/landing.go @@ -12,6 +12,7 @@ import ( type LandingData struct { BaseURL string AuthEnabled bool + SignupOpen bool Contact string ContactHref string Notice string @@ -27,7 +28,7 @@ type LandingData struct { // // It lists no models and no cluster state. Everything that says what is here // stays behind the key, including /v1/models. -func Landing(version string, authEnabled bool, contact, notice string) http.HandlerFunc { +func Landing(version string, authEnabled, signupOpen bool, contact, notice string) http.HandlerFunc { text, href := contactLink(contact) return func(w http.ResponseWriter, r *http.Request) { // Only the root: a catch-all would turn every mistyped path into this @@ -39,6 +40,7 @@ func Landing(version string, authEnabled bool, contact, notice string) http.Hand render(w, "landing", LandingData{ BaseURL: baseURL(r), AuthEnabled: authEnabled, + SignupOpen: signupOpen, Contact: text, ContactHref: href, Notice: notice, diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html new file mode 100644 index 0000000..2439c86 --- /dev/null +++ b/internal/web/templates/account.html @@ -0,0 +1,112 @@ +{{define "signup"}} + + +Sign up · llmgw +
+

Create an account

+

An operator approves it before it can be used.

+ +{{if .Done}} +

Thank you. Your account is waiting for approval.

+

You will not get an email from this gateway — it does not send any. +Tell whoever you asked for access that you have signed up.

+

Sign in once you have been approved.

+{{else}} +{{if .Error}}

{{.Error}}

{{end}} +
+ + + +

At least {{.MinPassword}} characters. Length is the only rule — + a long phrase beats a short one with punctuation in it.

+ +
+

Already have an account? Sign in.

+{{end}} + +
{{end}} + +{{define "login"}} + + +Sign in · llmgw +
+

Sign in

+ +{{if .Error}}

{{.Error}}

{{end}} +
+ + + +
+{{if .SignupOpen}}

No account? Sign up.

{{end}} + +
{{end}} + +{{define "profile"}} + + +Your keys · llmgw +
+
+

Your keys

+

{{.User.Name}} · {{.User.Email}} · project {{.User.ProjectID}}

+ +
+ +{{if .Error}}

{{.Error}}

{{end}} + +{{if .NewKey}} +

Your new key

+

This is shown once. Nothing can produce it again — only its hash is kept.

+
{{.NewKey}}
+

Put it in a password manager now.

+
curl {{.BaseURL}}/v1/chat/completions \
+  -H "Authorization: Bearer {{.NewKey}}" \
+  -H "Content-Type: application/json" \
+  -d '{"model": "MODEL", "messages": [{"role":"user","content":"hello"}]}'
+{{end}} + +

Create a key

+
+ + + +
+ +

Keys

+{{if .Keys}} + +{{range .Keys}} + + + + + + +{{end}}
NameKeyCreatedLast usedState
{{.Name}}{{.Prefix}}{{date .CreatedAt}}{{ago .LastUsedAt}}{{state .}}{{if eq (state .) "active"}} +
+ + +
+ {{end}}
+

Revoking takes effect within the gateway's cache window, up to a +minute. To rotate: create the new key, switch over, wait for "last used" on the +old one to stop moving, then revoke it.

+{{else}}

You have no keys yet.

{{end}} + +

Your limits

+{{if .Limits}} + +{{range .Limits}}{{end}}
LimitValue
{{.Name}}{{.Value}}
+{{else}}

No limits are set for you.

{{end}} + +
llmgw {{.Version}}{{.Now}}
+
{{end}} diff --git a/internal/web/templates/base.css b/internal/web/templates/base.css index 47fec45..8b2fea3 100644 --- a/internal/web/templates/base.css +++ b/internal/web/templates/base.css @@ -58,3 +58,17 @@ button:hover { filter: brightness(1.1); } ul { padding-left: 1.2rem; } li { margin: .25rem 0; } a { color: var(--accent); } +.wrap.narrow { max-width: 30rem; } +label { display: block; margin: .9rem 0; font-size: .9rem; font-weight: 600; } +input[type=text], input[type=email], input[type=password] { + display: block; width: 100%; margin-top: .3rem; font: inherit; font-weight: 400; + padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px; + background: var(--card); color: var(--fg); +} +input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.error { color: var(--bad); border: 1px solid var(--bad); border-radius: 6px; + padding: .6rem .8rem; background: var(--card); } +form.inline { display: inline; } +button.link { background: none; color: var(--accent); padding: 0; + text-decoration: underline; font-weight: 400; font-size: .9rem; } +button.link.danger { color: var(--bad); } diff --git a/internal/web/templates/landing.html b/internal/web/templates/landing.html index d29eac5..02eba43 100644 --- a/internal/web/templates/landing.html +++ b/internal/web/templates/landing.html @@ -35,17 +35,21 @@ reach it. That is a deployment choice and only safe on a trusted network.

{{end}}

Getting a key

-{{if .Contact}} +{{if .SignupOpen}} +

Create an account. Someone will approve it, and then you +can issue your own keys from your profile — as many as you +need, revoking them yourself when a laptop is retired or a script is done.

+

Already have an account? Sign in.

+{{else if .Contact}}

Ask {{if .ContactHref}}{{.Contact}}{{else}}{{.Contact}}{{end}} -for one.

+for one. {{if .AuthEnabled}}If you already have an account, +sign in.{{end}}

{{else}}

Keys are issued by whoever operates this gateway. If you do not know who that is, the address you reached this page at is the place to start asking.

{{end}} -

You will be sent a one-time link. Opening it shows the key once and then the -link stops working, so the key never has to travel through a chat window. Put it -in a password manager straight away — nothing can produce it again, because only -its hash is kept here.

+

A key is shown once and never again — only its hash is kept here — so put it in +a password manager straight away.

{{if .Notice}}

{{.Notice}}

{{end}} diff --git a/internal/web/web.go b/internal/web/web.go index e9512a1..7bdbeec 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -20,6 +20,8 @@ import ( "net/http" "strings" "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) //go:embed templates @@ -46,6 +48,25 @@ var funcs = template.FuncMap{ "units": func(micros int64) string { return fmt.Sprintf("%.2f", float64(micros)/1e6) }, + "date": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.Format("2006-01-02") + }, + // A key's state as the profile page shows it, computed here rather than in + // the template so "expired" cannot quietly become "active" when nobody + // updates the condition. + "state": func(k store.Key) string { + switch { + case !k.RevokedAt.IsZero(): + return "revoked" + case !k.ExpiresAt.IsZero() && !time.Now().Before(k.ExpiresAt): + return "expired" + default: + return "active" + } + }, } var tmpl = template.Must(template.New("").Funcs(funcs).ParseFS(files, "templates/*.html")) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index bcbbf57..2c564e9 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -24,7 +24,7 @@ func get(t *testing.T, h http.Handler, target string, headers map[string]string) } func TestLandingPageTellsYouHowToConnect(t *testing.T) { - code, body := get(t, Landing("v1.2.3", true, "", ""), "http://llmgw.example/", nil) + code, body := get(t, Landing("v1.2.3", true, false, "", ""), "http://llmgw.example/", nil) if code != http.StatusOK { t.Fatalf("status %d", code) } @@ -38,7 +38,7 @@ func TestLandingPageTellsYouHowToConnect(t *testing.T) { func TestLandingPageSaysNothingAboutWhatIsHere(t *testing.T) { // It is served without a key, to anyone who can reach the address. Anything // on it is public, so nothing about the cluster belongs on it. - _, body := get(t, Landing("v1", true, "", ""), "http://llmgw.example/", nil) + _, body := get(t, Landing("v1", true, false, "", ""), "http://llmgw.example/", nil) for _, leak := range []string{"llama", "qwen", "10.0.0.", "replica", "node01"} { if strings.Contains(strings.ToLower(body), leak) { t.Errorf("the landing page leaks %q", leak) @@ -49,11 +49,11 @@ func TestLandingPageSaysNothingAboutWhatIsHere(t *testing.T) { func TestLandingPageWarnsWhenTheGatewayIsOpen(t *testing.T) { // Someone who reaches this page on an unauthenticated gateway should learn // that from the page rather than by discovering they can use it. - _, open := get(t, Landing("v1", false, "", ""), "http://llmgw.example/", nil) + _, open := get(t, Landing("v1", false, false, "", ""), "http://llmgw.example/", nil) if !strings.Contains(open, "no API keys configured") { t.Error("an open gateway does not say so") } - _, closed := get(t, Landing("v1", true, "", ""), "http://llmgw.example/", nil) + _, closed := get(t, Landing("v1", true, false, "", ""), "http://llmgw.example/", nil) if strings.Contains(closed, "no API keys configured") { t.Error("a closed gateway claims to be open") } @@ -63,7 +63,7 @@ func TestTheExampleURLSurvivesAReverseProxy(t *testing.T) { // The process sees http and an internal host; the client used https and a // public name. An example naming the internal address is one nobody can // paste anywhere. - _, body := get(t, Landing("v1", true, "", ""), "http://10.0.0.140:8080/", map[string]string{ + _, body := get(t, Landing("v1", true, false, "", ""), "http://10.0.0.140:8080/", map[string]string{ "X-Forwarded-Proto": "https", "X-Forwarded-Host": "llmgw.mynodes.xyz", }) @@ -77,7 +77,7 @@ func TestTheExampleURLSurvivesAReverseProxy(t *testing.T) { func TestForwardedHeaderChainsTakeTheFirstEntry(t *testing.T) { // A chain of proxies appends rather than replaces. - _, body := get(t, Landing("v1", true, "", ""), "http://internal/", map[string]string{ + _, body := get(t, Landing("v1", true, false, "", ""), "http://internal/", map[string]string{ "X-Forwarded-Proto": "https, http", "X-Forwarded-Host": "llmgw.example, internal", }) @@ -89,7 +89,7 @@ func TestForwardedHeaderChainsTakeTheFirstEntry(t *testing.T) { func TestOnlyTheRootIsTheLandingPage(t *testing.T) { // A catch-all would turn every mistyped path into this page, hiding real // 404s from anyone debugging a client. - code, _ := get(t, Landing("v1", true, "", ""), "http://x/v1/chat/completionz", nil) + code, _ := get(t, Landing("v1", true, false, "", ""), "http://x/v1/chat/completionz", nil) if code != http.StatusNotFound { t.Errorf("a mistyped path returned %d", code) } @@ -235,12 +235,14 @@ func TestStaticIsNotAFileServer(t *testing.T) { func TestTheLandingPageSaysWhereToGetAKey(t *testing.T) { // A page that explains the API and leaves the reader with no route to using // it is where onboarding stops. - _, body := get(t, Landing("v1", true, "platform@example.com", ""), "http://x/", nil) + _, body := get(t, Landing("v1", true, false, "platform@example.com", ""), "http://x/", nil) if !strings.Contains(body, `href="mailto:platform@example.com"`) { t.Error("an address was not turned into a mailto link") } - if !strings.Contains(body, "one-time link") { - t.Error("the page does not say how a key will arrive") + // With signup closed, the route is a person; the page still has to say what + // arriving looks like. + if !strings.Contains(body, "shown once") { + t.Error("the page does not say a key cannot be recovered") } } @@ -284,7 +286,7 @@ func TestADangerousContactSchemeIsNeverLinked(t *testing.T) { t.Errorf("contactLink(%q) dropped the text entirely", bad) } - _, body := get(t, Landing("v1", true, bad, ""), "http://x/", nil) + _, body := get(t, Landing("v1", true, false, bad, ""), "http://x/", nil) if strings.Contains(body, "href=\"javascript") || strings.Contains(body, "href=\"data:") { t.Errorf("%q reached the page as a link", bad) } @@ -292,7 +294,7 @@ func TestADangerousContactSchemeIsNeverLinked(t *testing.T) { } func TestTheNoticeIsEscaped(t *testing.T) { - _, body := get(t, Landing("v1", true, "", ""), "http://x/", nil) + _, body := get(t, Landing("v1", true, false, "", ""), "http://x/", nil) if strings.Contains(body, "") { t.Error("the notice was rendered as markup") } @@ -302,8 +304,19 @@ func TestTheNoticeIsEscaped(t *testing.T) { } func TestWithoutAContactThePageStillSaysSomethingUseful(t *testing.T) { - _, body := get(t, Landing("v1", true, "", ""), "http://x/", nil) + _, body := get(t, Landing("v1", true, false, "", ""), "http://x/", nil) if !strings.Contains(body, "whoever operates this gateway") { t.Error("an unconfigured gateway leaves the reader with nothing") } } + +func TestWithSignupOpenTheLandingPagePointsAtIt(t *testing.T) { + // The route from "I found this address" to "I have a key" has to be on the + // page, or the reader is stuck whatever else it says. + _, body := get(t, Landing("v1", true, true, "", ""), "http://x/", nil) + for _, want := range []string{`href="/signup"`, `href="/login"`, `href="/profile"`} { + if !strings.Contains(body, want) { + t.Errorf("the page does not link to %s", want) + } + } +} diff --git a/llmgw-plan.md b/llmgw-plan.md index 452c0cf..eb8bd2e 100644 --- a/llmgw-plan.md +++ b/llmgw-plan.md @@ -1412,6 +1412,10 @@ The launch path is now built too: caller send its whole allowance at the end of one window and again at the start of the next, so the observed rate doubles exactly when the system is least able to take it. +- [x] Accounts: signup, operator approval, sessions and a profile page where a + person issues and revokes their own keys. Approval stays on the CLI — + having a shell on the control plane is what makes someone an + administrator, and there are no roles to get wrong. - [x] Onboarding: one-time claim links (the key is sealed with the link's own token, which is never stored), `GET /me` for a user's own limits and spend, and `docs/onboarding.md`. Sending the key itself was the weak link diff --git a/test/e2e/signup_test.go b/test/e2e/signup_test.go new file mode 100644 index 0000000..5766899 --- /dev/null +++ b/test/e2e/signup_test.go @@ -0,0 +1,319 @@ +package e2e + +import ( + "context" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// browser is a client that keeps cookies and does not follow redirects, so a +// test can see them. +type browser struct { + t *testing.T + base string + c *http.Client +} + +func newBrowser(t *testing.T, base string) *browser { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &browser{t: t, base: base, c: &http.Client{ + Jar: jar, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }} +} + +func (b *browser) get(path string) (int, string) { + b.t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, b.base+path, nil) + resp, err := b.c.Do(req) + if err != nil { + b.t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) +} + +func (b *browser) post(path string, form url.Values) (int, string, string) { + b.t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, + b.base+path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := b.c.Do(req) + if err != nil { + b.t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body), resp.Header.Get("Location") +} + +// csrf pulls the token the server put in the page's forms. +func csrf(t *testing.T, body string) string { + t.Helper() + const marker = `name="csrf" value="` + i := strings.Index(body, marker) + if i < 0 { + t.Fatal("no csrf token in the page") + } + rest := body[i+len(marker):] + return rest[:strings.IndexByte(rest, '"')] +} + +func signupHarness(t *testing.T) (*harness, string) { + t.Helper() + path, _ := provision(t) + h := start(t, 2, func(c *config.Server) { + c.Auth = config.Auth{ + Enabled: true, Store: path, SignupOpen: true, + CacheTTL: 50 * time.Millisecond, + } + }) + return h, path +} + +const password = "a long enough passphrase" + +func TestAnAccountIsUselessUntilItIsApproved(t *testing.T) { + // The whole reason a public signup form is safe: it produces something that + // cannot do anything. + h, path := signupHarness(t) + b := newBrowser(t, h.router.URL) + + code, body, _ := b.post("/signup", url.Values{ + "email": {"ayse@example.com"}, "name": {"Ayse"}, "password": {password}, + }) + if code != http.StatusOK || !strings.Contains(body, "waiting for approval") { + t.Fatalf("signup returned %d: %s", code, body) + } + + code, body, _ = b.post("/login", url.Values{ + "email": {"ayse@example.com"}, "password": {password}, + }) + if code != http.StatusUnauthorized || !strings.Contains(body, "waiting for approval") { + t.Fatalf("a pending account signed in: %d", code) + } + + // The profile page is not reachable either. + if code, _ := b.get("/profile"); code != http.StatusSeeOther { + t.Errorf("an unauthenticated visit to /profile returned %d", code) + } + + // Approve, and it all works. + withStore(t, path, func(s *store.SQLite) { + users, err := s.Users(context.Background(), store.UserPending) + if err != nil || len(users) != 1 { + t.Fatalf("pending users: %v %v", users, err) + } + if err := s.ApproveUser(context.Background(), users[0].ID, "proj", "test", time.Now()); err != nil { + t.Fatal(err) + } + }) + + code, _, loc := b.post("/login", url.Values{ + "email": {"ayse@example.com"}, "password": {password}, + }) + if code != http.StatusSeeOther || loc != "/profile" { + t.Fatalf("sign-in after approval returned %d to %q", code, loc) + } +} + +func TestAUserIssuesTheirOwnKeyAndItWorks(t *testing.T) { + h, path := signupHarness(t) + b := newBrowser(t, h.router.URL) + signUpAndApprove(t, b, path, "ayse@example.com") + + _, profile := b.get("/profile") + token := csrf(t, profile) + + code, body, _ := b.post("/profile/keys", url.Values{ + "csrf": {token}, "name": {"laptop"}, + }) + if code != http.StatusOK { + t.Fatalf("creating a key returned %d", code) + } + key := extractKey(t, body) + + // The key the person made for themselves is a real key. + if code, msg := h.call(t, key, testModel); code != http.StatusOK { + t.Fatalf("a self-issued key does not work: %d %s", code, msg) + } + + // And it is listed afterwards, without the secret. + _, profile = b.get("/profile") + if !strings.Contains(profile, "laptop") { + t.Error("the key is not listed") + } + if strings.Contains(profile, key) { + t.Error("the profile page shows the key again; it is meant to be shown once") + } +} + +func TestAUserCannotRevokeSomeoneElsesKey(t *testing.T) { + // The ownership check is in the statement rather than a prior read, so a + // guessed id from another account changes nothing. + h, path := signupHarness(t) + + ayse := newBrowser(t, h.router.URL) + signUpAndApprove(t, ayse, path, "ayse@example.com") + _, p1 := ayse.get("/profile") + _, body, _ := ayse.post("/profile/keys", url.Values{"csrf": {csrf(t, p1)}, "name": {"ayse-key"}}) + ayseKey := extractKey(t, body) + + var ayseKeyID string + withStore(t, path, func(s *store.SQLite) { + users, _ := s.Users(context.Background(), store.UserActive) + for _, u := range users { + if u.Email == "ayse@example.com" { + keys, _ := s.KeysForUser(context.Background(), u.ID) + if len(keys) != 1 { + t.Fatalf("ayse has %d keys", len(keys)) + } + ayseKeyID = keys[0].ID + } + } + }) + + mehmet := newBrowser(t, h.router.URL) + signUpAndApprove(t, mehmet, path, "mehmet@example.com") + _, p2 := mehmet.get("/profile") + + if code, _, _ := mehmet.post("/profile/keys/"+ayseKeyID+"/revoke", + url.Values{"csrf": {csrf(t, p2)}}); code != http.StatusSeeOther { + t.Fatalf("status %d", code) + } + // It must still work. + if code, msg := h.call(t, ayseKey, testModel); code != http.StatusOK { + t.Errorf("someone else revoked this key: %d %s", code, msg) + } +} + +func TestAFormWithoutItsTokenIsRefused(t *testing.T) { + // A cookie authenticates the browser, not the page that asked. Without this, + // any site the person visits can post here and have the browser attach the + // session for it. + h, path := signupHarness(t) + b := newBrowser(t, h.router.URL) + signUpAndApprove(t, b, path, "ayse@example.com") + + for _, bad := range []url.Values{ + {"name": {"x"}}, + {"csrf": {""}, "name": {"x"}}, + {"csrf": {"forged"}, "name": {"x"}}, + } { + if code, _, _ := b.post("/profile/keys", bad); code != http.StatusBadRequest { + t.Errorf("a form with csrf=%q was accepted: %d", bad.Get("csrf"), code) + } + } +} + +func TestSigningOutEndsTheSession(t *testing.T) { + h, path := signupHarness(t) + b := newBrowser(t, h.router.URL) + signUpAndApprove(t, b, path, "ayse@example.com") + + _, profile := b.get("/profile") + if _, _, _ = b.post("/logout", url.Values{"csrf": {csrf(t, profile)}}); false { + t.Fatal("unreachable") + } + if code, _ := b.get("/profile"); code != http.StatusSeeOther { + t.Errorf("the profile was still reachable after signing out: %d", code) + } +} + +func TestSuspensionClosesOpenSessions(t *testing.T) { + h, path := signupHarness(t) + b := newBrowser(t, h.router.URL) + id := signUpAndApprove(t, b, path, "ayse@example.com") + + if code, _ := b.get("/profile"); code != http.StatusOK { + t.Fatalf("profile before suspension: %d", code) + } + withStore(t, path, func(s *store.SQLite) { + if err := s.SetUserState(context.Background(), id, store.UserSuspended); err != nil { + t.Fatal(err) + } + }) + if code, _ := b.get("/profile"); code != http.StatusSeeOther { + t.Errorf("a suspended account kept its session: %d", code) + } +} + +func TestSignupCanBeClosed(t *testing.T) { + path, _ := provision(t) + h := start(t, 2, func(c *config.Server) { + c.Auth = config.Auth{Enabled: true, Store: path, SignupOpen: false} + }) + b := newBrowser(t, h.router.URL) + + if code, body := b.get("/signup"); code == http.StatusOK && strings.Contains(body, "