Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
724 lines
24 KiB
Go
724 lines
24 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/audit"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
|
"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
|
|
ChangePassword(ctx context.Context, pw account.PasswordChanger,
|
|
u store.User, current, next string) error
|
|
OpenSession(ctx context.Context, userID string) (token, csrf string, err error)
|
|
}
|
|
|
|
// 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)
|
|
// Where a person may make keys, and what applies there.
|
|
ProjectsFor(ctx context.Context, userID string) ([]store.ProjectPath, error)
|
|
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
|
|
ResolveFor(ctx context.Context, callerID, userID, projectID string) (store.Effective, error)
|
|
}
|
|
|
|
// AccountPages serves signup, sign-in and the profile.
|
|
type AccountPages struct {
|
|
accounts Accounts
|
|
keys KeyOwner
|
|
passwords account.PasswordChanger
|
|
version string
|
|
log *slog.Logger
|
|
// served, when set, names the models an endpoint is currently serving. The
|
|
// curl example on this page fills its model field from it: every other
|
|
// field in that snippet is real, so the one placeholder left in it was
|
|
// copied verbatim and sent as the literal string MODEL.
|
|
served func() []string
|
|
// hasMirror is set when /admin/mirror is registered, so the other admin
|
|
// pages can link to it and only then.
|
|
hasMirror bool
|
|
// hasQuotas is set when /admin/quotas is registered.
|
|
hasQuotas bool
|
|
// oidc is sign-in through an identity provider; nil when there is none.
|
|
oidc *oidcLogin
|
|
// audit records every change made through these pages; nil records none.
|
|
audit audit.Sink
|
|
// hasGuard is set when /admin/guard is registered.
|
|
hasGuard bool
|
|
// guardFor names the guardrail policies in force for someone, and
|
|
// whether they keep nothing; nil when there are none.
|
|
guardFor func(guard.Subject) (string, bool)
|
|
// capacity sets capacity quotas, from the quotas page; nil leaves them off.
|
|
// history is the metrics store, for what only it knows: the GPU time
|
|
// each owner held. Nil without one.
|
|
history History
|
|
capacity CapacityAdmin
|
|
// personalOff refuses personal keys; see SetPersonalOff.
|
|
personalOff bool
|
|
// personalDefaults is what personal use falls under by default.
|
|
personalDefaults *store.Limits
|
|
// orgs is set when /org is mounted, so the menu can offer it to managers.
|
|
orgs OrgViewer
|
|
// llamaVersion is the llama.cpp build nodes install, for judging whether a
|
|
// GGUF can be served before it is fetched or catalogued.
|
|
llamaVersion string
|
|
// priced says whether a budget can ever be spent against.
|
|
priced Priced
|
|
// registry browses a model registry, or is nil where there is no way out.
|
|
registry Registry
|
|
// kinds remembers what each held GGUF file is, by its address: a held
|
|
// version never changes, so it is read once.
|
|
kinds sync.Map
|
|
// catalog puts a mirrored model into service; fleet says whether anything
|
|
// could run it. Both nil where the mirror page is read-only.
|
|
catalog CatalogAdmin
|
|
fleet Source
|
|
// The stores behind the people page's three tabs — the tree and its
|
|
// people, the limits, the usage — each set when its part is mounted, so
|
|
// the page draws the parts that exist.
|
|
peopleStore AccountAdmin
|
|
quotaStore QuotaAdmin
|
|
usageStore UsageAdmin
|
|
usagePriced Priced
|
|
}
|
|
|
|
// ServedModels supplies the names the profile page's example should use.
|
|
//
|
|
// A setter rather than a constructor argument because only the router knows
|
|
// what is being served, and the page is useful without it — with no source the
|
|
// example falls back to a placeholder, which is what it always was.
|
|
func (p *AccountPages) ServedModels(f func() []string) { p.served = f }
|
|
|
|
// exampleModel picks the model the copy-pasteable example should name.
|
|
//
|
|
// A real model over a simulated one is not expressible here — this package does
|
|
// not know which is which — so it takes the first served name and relies on the
|
|
// router listing real models first.
|
|
func (p *AccountPages) exampleModel() string {
|
|
if p.served == nil {
|
|
return "<model-name>"
|
|
}
|
|
if names := p.served(); len(names) > 0 {
|
|
return names[0]
|
|
}
|
|
return "<model-name>"
|
|
}
|
|
|
|
// NewAccountPages builds the handlers.
|
|
func NewAccountPages(a Accounts, k KeyOwner, pw account.PasswordChanger,
|
|
version string, log *slog.Logger,
|
|
) *AccountPages {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &AccountPages{accounts: a, keys: k, passwords: pw, 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)
|
|
// Each signup costs a password hash and a row: a few a minute from
|
|
// one address is a person, more is a script.
|
|
mux.HandleFunc("POST /signup", rationed(newRation(5, 10), p.signup))
|
|
}
|
|
mux.HandleFunc("GET /login", p.loginForm)
|
|
mux.HandleFunc("POST /login", rationed(newRation(30, 30), p.login))
|
|
mux.HandleFunc("POST /logout", p.logout)
|
|
mux.HandleFunc("GET /profile", p.profile)
|
|
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
|
|
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
|
|
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
|
|
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
|
|
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
|
|
}
|
|
|
|
// --- 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:
|
|
// Nothing of the database's words on the page; a reference that finds
|
|
// them in the log for whoever runs the gateway.
|
|
ref := errorRef()
|
|
p.log.Error("signup failed", "ref", ref, "err", err)
|
|
d.Error = "something went wrong on our side, and your account was not created. " +
|
|
"Try again later; if it keeps happening, give an operator this reference: " + ref
|
|
}
|
|
render(w, "signup", d)
|
|
}
|
|
|
|
// --- sign in and out ---
|
|
|
|
// ssoLabel is what the sign-in button names, or "" without one.
|
|
func (p *AccountPages) ssoLabel() string {
|
|
if p.oidc == nil {
|
|
return ""
|
|
}
|
|
return or(p.oidc.cfg.Label, "single sign-on")
|
|
}
|
|
|
|
type loginData struct {
|
|
Email, Error string
|
|
SignupOpen bool
|
|
Version string
|
|
// SSO is the identity provider's label, when sign-in through one is on.
|
|
SSO string
|
|
// CSRF is the form's token, matched by a cookie.
|
|
CSRF string
|
|
}
|
|
|
|
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
render(w, "login", loginData{SSO: p.ssoLabel(), SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)})
|
|
}
|
|
|
|
func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if !loginTokenOK(r) {
|
|
// Not this site's form: another one posting a visitor into an
|
|
// account of its choosing, or a form left open past its hour.
|
|
renderStatus(w, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
|
|
Error: "the form had expired; sign in again", CSRF: setLoginToken(w, r)})
|
|
return
|
|
}
|
|
|
|
ctx := account.WithClient(r.Context(), clientAddr(r))
|
|
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
|
|
if err != nil {
|
|
d := loginData{SSO: p.ssoLabel(), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)}
|
|
switch {
|
|
case errors.Is(err, account.ErrBadCredentials),
|
|
errors.Is(err, account.ErrNotApproved),
|
|
errors.Is(err, account.ErrSuspended),
|
|
errors.Is(err, account.ErrTooManyTries),
|
|
errors.Is(err, account.ErrBusy):
|
|
d.Error = err.Error()
|
|
default:
|
|
p.log.Error("sign-in failed", "err", err)
|
|
d.Error = "something went wrong; try again"
|
|
}
|
|
renderStatus(w, http.StatusUnauthorized, "login", d)
|
|
return
|
|
}
|
|
// Through the provider only, except an administrator's way in for when
|
|
// the provider itself is broken.
|
|
if p.oidc != nil && p.oidc.cfg.NoPasswords {
|
|
if _, u, err := p.accounts.Resolve(ctx, token); err != nil || !u.IsAdmin {
|
|
_ = p.accounts.SignOut(ctx, token)
|
|
renderStatus(w, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(), Email: email,
|
|
SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r),
|
|
Error: "this gateway signs people in with " + p.ssoLabel() + ", not passwords"})
|
|
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 }
|
|
|
|
// contextLimits is what applies to a person in one place they can spend.
|
|
type contextLimits struct {
|
|
Label string
|
|
Rows []limitRow
|
|
// Use is what the person spent here over the window, and Budgets how
|
|
// much is left of each budget that applies; both nil without usage.
|
|
Use *placeUse
|
|
Budgets []budgetBar
|
|
|
|
project string
|
|
chain []store.Limits
|
|
}
|
|
|
|
type profileData struct {
|
|
Nav Nav
|
|
// Contexts are where a key can be made: the person's projects, and
|
|
// themselves when personal use is on.
|
|
Contexts []ChatContext
|
|
// Where names each key's project by path; a personal key has none.
|
|
Where map[string]string
|
|
Memberships []store.Membership
|
|
LimitsBy []contextLimits
|
|
// Own is the person's use over the last thirty days; nil when it cannot
|
|
// be read.
|
|
Own *ownUse
|
|
User store.User
|
|
Keys []store.Key
|
|
Limits []limitRow
|
|
NewKey string
|
|
// Connect is an editor just connected: its key and configuration, shown
|
|
// once, like any new key.
|
|
Connect *connectView
|
|
// Tools and Models are what the connect form offers.
|
|
Tools []struct{ Key, Name string }
|
|
Models []string
|
|
Error string
|
|
|
|
PasswordChanged bool
|
|
PasswordError string
|
|
MinPassword int
|
|
|
|
CSRF string
|
|
BaseURL string
|
|
// ExampleModel is a model that is actually being served, so the example on
|
|
// the page can be copied and run as it stands.
|
|
ExampleModel 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"
|
|
}
|
|
project := r.FormValue("context")
|
|
if project == personalValue {
|
|
if p.personalOff {
|
|
p.renderProfile(w, r, sess, user, "", "personal keys are off on this gateway; choose a project")
|
|
return
|
|
}
|
|
project = ""
|
|
}
|
|
secret, k, err := store.NewKey(randomKeyID(), name, project)
|
|
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 {
|
|
if errors.Is(err, store.ErrNotMember) {
|
|
p.renderProfile(w, r, sess, user, "", "you are not a member of that project")
|
|
return
|
|
}
|
|
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,
|
|
) {
|
|
p.render(w, r, sess, user, profileData{NewKey: newKey, Error: errMsg})
|
|
}
|
|
|
|
func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
|
|
sess store.Session, user store.User, d profileData,
|
|
) {
|
|
d.User = user
|
|
d.Nav = p.navFor(r, user, "keys")
|
|
d.MinPassword = account.MinPasswordLen
|
|
d.BaseURL = baseURL(r)
|
|
d.ExampleModel = p.exampleModel()
|
|
d.Tools = editorTools
|
|
if p.served != nil {
|
|
d.Models = p.served()
|
|
}
|
|
d.Version = p.version
|
|
d.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
|
|
p.fillPlaces(r.Context(), user, &d)
|
|
render(w, "profile", d)
|
|
}
|
|
|
|
// personalValue is how a form names the person's own, project-less use.
|
|
const personalValue = "personal"
|
|
|
|
// SetGuardPolicies has the profile say which guardrail policies apply where.
|
|
func (p *AccountPages) SetGuardPolicies(f func(guard.Subject) (string, bool)) { p.guardFor = f }
|
|
|
|
// SetPersonalOff says personal keys are refused on this gateway.
|
|
func (p *AccountPages) SetPersonalOff(off bool) { p.personalOff = off }
|
|
|
|
// fillPlaces adds where the person can make keys, where their keys are, and
|
|
// what applies in each place, resolved the way a request is: the whole chain
|
|
// above a project, per-member limits, and the person's own.
|
|
func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profileData) {
|
|
d.Where = map[string]string{}
|
|
projects, err := p.keys.ProjectsFor(ctx, user.ID)
|
|
if err != nil {
|
|
p.log.Warn("could not list a person's projects", "err", err)
|
|
}
|
|
for _, pp := range projects {
|
|
d.Contexts = append(d.Contexts, ChatContext{Value: pp.ID, Label: pp.Path})
|
|
d.Where[pp.ID] = pp.Path
|
|
}
|
|
if !p.personalOff {
|
|
d.Contexts = append(d.Contexts, ChatContext{Value: personalValue, Label: "Personal"})
|
|
}
|
|
if d.Memberships, err = p.keys.MembershipsOf(ctx, user.ID); err != nil {
|
|
p.log.Warn("could not list a person's memberships", "err", err)
|
|
}
|
|
for _, c := range d.Contexts {
|
|
project := c.Value
|
|
if project == personalValue {
|
|
project = ""
|
|
}
|
|
e, err := p.keys.ResolveFor(ctx, "session:"+user.ID, user.ID, project)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rows := limitRows(e)
|
|
if p.guardFor != nil {
|
|
names, keepNothing := p.guardFor(guard.Subject{UserID: user.ID, ProjectID: e.ProjectID,
|
|
TeamID: e.TeamID, OrgID: e.OrgID})
|
|
if names != "" {
|
|
v := strings.ReplaceAll(names, ",", ", ")
|
|
if keepNothing {
|
|
v += " — nothing is kept: no conversations, notes or outside models"
|
|
}
|
|
rows = append(rows, limitRow{"Guardrails", v})
|
|
}
|
|
}
|
|
d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: rows,
|
|
project: project, chain: e.Chain})
|
|
}
|
|
p.fillOwnUse(ctx, user, d)
|
|
}
|
|
|
|
func limitRows(e store.Effective) []limitRow {
|
|
var out []limitRow
|
|
if e.RPM != nil {
|
|
out = append(out, limitRow{"Requests per minute", itoa(*e.RPM)})
|
|
}
|
|
if e.TPM != nil {
|
|
out = append(out, limitRow{"Tokens per minute", itoa(*e.TPM)})
|
|
}
|
|
if e.Budget != nil {
|
|
out = append(out, limitRow{"Budget", units(*e.Budget) + " per " + string(e.BudgetPeriod)})
|
|
}
|
|
if e.AllowedModels != nil {
|
|
if len(e.AllowedModels) == 0 {
|
|
out = append(out, limitRow{"Models", "none"})
|
|
} else {
|
|
out = append(out, limitRow{"Models", strings.Join(e.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 {
|
|
return clientip.Proto(r) == "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")
|
|
}
|
|
|
|
func (p *AccountPages) changePassword(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
|
|
}
|
|
|
|
err := p.accounts.ChangePassword(r.Context(), p.passwords, user,
|
|
r.FormValue("current"), r.FormValue("new"))
|
|
if err != nil {
|
|
d := profileData{}
|
|
switch {
|
|
case errors.Is(err, account.ErrBadCredentials):
|
|
d.PasswordError = "that is not your current password"
|
|
case errors.Is(err, account.ErrWeakPassword),
|
|
errors.Is(err, account.ErrSamePassword),
|
|
errors.Is(err, account.ErrTooManyTries):
|
|
d.PasswordError = err.Error()
|
|
default:
|
|
p.log.Error("could not change a password", "err", err)
|
|
d.PasswordError = "something went wrong; try again"
|
|
}
|
|
p.render(w, r, sess, user, d)
|
|
return
|
|
}
|
|
|
|
// The change closed every session including this one, which is the point.
|
|
// Open a new one for the browser in front of us, so the person who just
|
|
// proved their password is not thrown back to the sign-in page.
|
|
token, csrf, err := p.accounts.OpenSession(r.Context(), user.ID)
|
|
if err != nil {
|
|
p.log.Error("could not reopen a session", "err", err)
|
|
clearSessionCookie(w, r)
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
setSessionCookie(w, r, token)
|
|
setCSRFCookie(w, r, csrf)
|
|
|
|
// The new CSRF cookie is set on this response, so the forms rendered now
|
|
// must carry the same value rather than the one the request arrived with.
|
|
newSess, newUser, rerr := p.accounts.Resolve(r.Context(), token)
|
|
if rerr != nil {
|
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
|
return
|
|
}
|
|
d := profileData{PasswordChanged: true}
|
|
d.CSRF = csrf
|
|
p.renderWithCSRF(w, r, newSess, newUser, d)
|
|
}
|
|
|
|
// renderWithCSRF is render for the one case where the token in the cookie jar
|
|
// is not yet the one the browser has: the response that sets a new one.
|
|
func (p *AccountPages) renderWithCSRF(w http.ResponseWriter, r *http.Request,
|
|
sess store.Session, user store.User, d profileData,
|
|
) {
|
|
csrf := d.CSRF
|
|
d.User = user
|
|
d.MinPassword = account.MinPasswordLen
|
|
d.BaseURL = baseURL(r)
|
|
d.ExampleModel = p.exampleModel()
|
|
d.Version = p.version
|
|
d.Now = time.Now().UTC().Format(time.RFC3339)
|
|
d.CSRF = csrf
|
|
// The menu's sign-out form too: it is the first form on the page.
|
|
d.Nav = p.navFor(r, user, "keys")
|
|
d.Nav.CSRF = csrf
|
|
_ = 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
|
|
p.fillPlaces(r.Context(), user, &d)
|
|
render(w, "profile", d)
|
|
}
|
|
|
|
// SetLlamaCppVersion tells the pages which llama.cpp build the nodes run.
|
|
func (p *AccountPages) SetLlamaCppVersion(v string) { p.llamaVersion = v }
|
|
|
|
// errorRef is a short reference for an error a page does not explain, logged
|
|
// beside the error itself.
|
|
func errorRef() string {
|
|
var b [4]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "err-" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// SetHistory gives the pages the metrics store.
|
|
func (p *AccountPages) SetHistory(h History) { p.history = h }
|