Let people sign up, be approved, and issue their own keys
CI / test (push) Successful in 1m9s

Every key needed an operator with a shell. This adds the login system that
made that necessary, which is the thing I argued against building until it was
asked for — so it is built as if it will be attacked, because a public login
form is.

Signing up produces an account that can do nothing. Approval is what makes it
work and stays on the CLI: having a shell on the control plane is what makes
someone an administrator, and that is a smaller thing to get right than roles
and an admin UI that mints credentials.

The details that matter:

Passwords are argon2id, where a slow hash is correct — the input is chosen by
a human and has a dictionary, which is the opposite of an API key. Both kinds
of authentication now live in this codebase and they are in separate packages
so neither set of assumptions leaks into the other. Length is the only rule:
composition rules push people towards Password1! and cost more entropy than
they buy.

Signup never says whether an address already has an account, and sign-in
answers the same way for a wrong password and a missing account — verifying
against a dummy hash when there is no user, so a stopwatch cannot enumerate
accounts. Account state is checked after the password, not before, so guessing
an address reveals nothing about it. Guessing is bounded per address rather
than per IP: an attacker has many addresses to come from and one to guess
against, and limiting by IP locks out an office behind one NAT.

Sessions are server-side, so signing out and suspending take effect
immediately rather than whenever a token expires; suspension is checked in the
same query that resolves the session, because a second round trip is a check
somebody eventually forgets. Every state-changing form carries a CSRF token
verified against a hash in the session — a cookie authenticates the browser,
not the page that asked.

Key ownership is enforced in the UPDATE rather than by reading first, so a
guessed key id from another account changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5 committed 2026-09-16 09:32:35 +02:00
1 parent 8d35faf6f8
commit 216fc9bb03
22 files changed
+2263 -39

No files matched your search

+6
View File
@@ -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
+82 -17
View File
@@ -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 <command> --store /var/lib/llmgw/llmgw.db
sudo -u llmgw llmgw <command> --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.
+312
View File
@@ -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)
}
}
+112
View File
@@ -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
}
+266
View File
@@ -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[:]))
}
+1
View File
@@ -41,6 +41,7 @@ func Root() *cobra.Command {
newAgentCmd(),
newCtlCmd(),
newTenancyCmd(),
newUsersCmd(),
newSimEngineCmd(),
)
return root
+186
View File
@@ -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 <user-id>",
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 + " <user-id>",
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 <email>",
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
}
+5
View File
@@ -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"`
+10 -1
View File
@@ -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
+8 -1
View File
@@ -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,
@@ -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);
+127
View File
@@ -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
}
+199
View File
@@ -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
}
+393
View File
@@ -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")
}
+3 -1
View File
@@ -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,
+112
View File
@@ -0,0 +1,112 @@
{{define "signup"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign up · llmgw</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap narrow">
<header><h1>Create an account</h1>
<p class="sub">An operator approves it before it can be used.</p></header>
{{if .Done}}
<p>Thank you. Your account is waiting for approval.</p>
<p class="note">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.</p>
<p><a href="/login">Sign in</a> once you have been approved.</p>
{{else}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" autocomplete="on">
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
<label>Name<input type="text" name="name" value="{{.Name}}" autocomplete="name"></label>
<label>Password<input type="password" name="password" required minlength="{{.MinPassword}}"
autocomplete="new-password"></label>
<p class="note">At least {{.MinPassword}} characters. Length is the only rule —
a long phrase beats a short one with punctuation in it.</p>
<button type="submit">Sign up</button>
</form>
<p class="note">Already have an account? <a href="/login">Sign in</a>.</p>
{{end}}
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
</div></body></html>{{end}}
{{define "login"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · llmgw</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap narrow">
<header><h1>Sign in</h1></header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" autocomplete="on">
<label>Email<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
<label>Password<input type="password" name="password" required autocomplete="current-password"></label>
<button type="submit">Sign in</button>
</form>
{{if .SignupOpen}}<p class="note">No account? <a href="/signup">Sign up</a>.</p>{{end}}
<footer><span>llmgw {{.Version}}</span><span><a href="/">about this gateway</a></span></footer>
</div></body></html>{{end}}
{{define "profile"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your keys · llmgw</title><link rel="stylesheet" href="/_static/base.css">
</head><body><div class="wrap">
<header>
<h1>Your keys</h1>
<p class="sub">{{.User.Name}} · {{.User.Email}} · project {{.User.ProjectID}}</p>
<nav>
<a href="/">about this gateway</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="link">Sign out</button>
</form>
</nav>
</header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .NewKey}}
<h2>Your new key</h2>
<p>This is shown once. Nothing can produce it again — only its hash is kept.</p>
<pre>{{.NewKey}}</pre>
<p>Put it in a password manager now.</p>
<pre>curl {{.BaseURL}}/v1/chat/completions \
-H "Authorization: Bearer {{.NewKey}}" \
-H "Content-Type: application/json" \
-d '{"model": "MODEL", "messages": [{"role":"user","content":"hello"}]}'</pre>
{{end}}
<h2>Create a key</h2>
<form method="post" action="/profile/keys">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>What is it for?<input type="text" name="name" placeholder="laptop, ci, notebook" required></label>
<button type="submit">Create</button>
</form>
<h2>Keys</h2>
{{if .Keys}}
<table><tr><th>Name</th><th>Key</th><th>Created</th><th>Last used</th><th>State</th><th></th></tr>
{{range .Keys}}<tr>
<td>{{.Name}}</td>
<td><code>{{.Prefix}}</code></td>
<td>{{date .CreatedAt}}</td>
<td>{{ago .LastUsedAt}}</td>
<td class="state state-{{state .}}">{{state .}}</td>
<td>{{if eq (state .) "active"}}
<form method="post" action="/profile/keys/{{.ID}}/revoke">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">Revoke</button>
</form>
{{end}}</td>
</tr>{{end}}</table>
<p class="note">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.</p>
{{else}}<p class="empty">You have no keys yet.</p>{{end}}
<h2>Your limits</h2>
{{if .Limits}}
<table><tr><th>Limit</th><th class="num">Value</th></tr>
{{range .Limits}}<tr><td>{{.Name}}</td><td class="num">{{.Value}}</td></tr>{{end}}</table>
{{else}}<p class="empty">No limits are set for you.</p>{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div></body></html>{{end}}
+14
View File
@@ -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); }
+10 -6
View File
@@ -35,17 +35,21 @@ reach it. That is a deployment choice and only safe on a trusted network.</p>
{{end}}
<h2>Getting a key</h2>
{{if .Contact}}
{{if .SignupOpen}}
<p><a href="/signup">Create an account</a>. Someone will approve it, and then you
can issue your own keys from your <a href="/profile">profile</a> — as many as you
need, revoking them yourself when a laptop is retired or a script is done.</p>
<p class="note">Already have an account? <a href="/login">Sign in</a>.</p>
{{else if .Contact}}
<p>Ask {{if .ContactHref}}<a href="{{.ContactHref}}">{{.Contact}}</a>{{else}}<strong>{{.Contact}}</strong>{{end}}
for one.</p>
for one. {{if .AuthEnabled}}If you already have an account,
<a href="/login">sign in</a>.{{end}}</p>
{{else}}
<p>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.</p>
{{end}}
<p>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.</p>
<p>A key is shown once and never again — only its hash is kept here — so put it in
a password manager straight away.</p>
{{if .Notice}}<p class="note">{{.Notice}}</p>{{end}}
+21
View File
@@ -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"))
+26 -13
View File
@@ -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, "", "<script>alert(1)</script>"), "http://x/", nil)
_, body := get(t, Landing("v1", true, false, "", "<script>alert(1)</script>"), "http://x/", nil)
if strings.Contains(body, "<script>alert(1)</script>") {
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)
}
}
}
+4
View File
@@ -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
+319
View File
@@ -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, "<form") {
t.Errorf("the signup form is served although signup is closed: %d", code)
}
// The status the mux gives depends on which catch-all the path falls
// through to, which is not the point. What matters is that no account
// appears.
if code, _, _ := b.post("/signup", url.Values{
"email": {"a@example.com"}, "password": {password},
}); code == http.StatusOK || code == http.StatusSeeOther {
t.Errorf("signup accepted a post although it is closed: %d", code)
}
withStore(t, path, func(s *store.SQLite) {
users, err := s.Users(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if len(users) != 0 {
t.Errorf("%d accounts exist although signup is closed", len(users))
}
})
}
// signUpAndApprove runs the operator's half and returns the new user's id.
func signUpAndApprove(t *testing.T, b *browser, path, email string) string {
t.Helper()
if code, _, _ := b.post("/signup", url.Values{
"email": {email}, "name": {email}, "password": {password},
}); code != http.StatusOK {
t.Fatalf("signup returned %d", code)
}
var id string
withStore(t, path, func(s *store.SQLite) {
users, err := s.Users(context.Background(), store.UserPending)
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.Email == email {
id = u.ID
}
}
if id == "" {
t.Fatalf("no pending account for %s", email)
}
if err := s.ApproveUser(context.Background(), id, "proj", "test", time.Now()); err != nil {
t.Fatal(err)
}
})
if code, _, _ := b.post("/login", url.Values{
"email": {email}, "password": {password},
}); code != http.StatusSeeOther {
t.Fatalf("sign-in returned %d", code)
}
return id
}