A new internal/clientip says who sent a request and how: the connection's address, scheme and Host, or through a configured proxy what it forwarded. The base URL in pages and setup scripts, isHTTPS for cookies, and the per-address limits of auth and the sign-in forms use it. /setup scripts are no-store. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
116 lines
3.9 KiB
Go
116 lines
3.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
|
|
)
|
|
|
|
// Middleware authenticates every request before it reaches the router.
|
|
//
|
|
// It is a wrapper rather than a check inside the handler so there is no path
|
|
// into the API that forgets to call it. Adding a route cannot accidentally add
|
|
// an unauthenticated one.
|
|
func Middleware(a *Authenticator, log *slog.Logger, exempt ...string) func(http.Handler) http.Handler {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isExempt(r.URL.Path, exempt) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
p, err := a.AuthenticateFrom(r.Context(), BearerToken(r), clientAddr(r))
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
// The key prefix, never the key: a rejected credential in a log
|
|
// is still a credential.
|
|
log.Debug("rejected a request", "path", r.URL.Path,
|
|
"reason", err, "remote", r.RemoteAddr)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
|
|
})
|
|
}
|
|
}
|
|
|
|
// isExempt reports whether a path is served without a key.
|
|
//
|
|
// An entry ending in "/" is a prefix rule, with one exception that matters
|
|
// enormously: "/" itself is exact. Treating it as a prefix would match every
|
|
// path there is, so exempting the landing page would silently exempt the whole
|
|
// API — authentication still installed, still logging, and letting everything
|
|
// through.
|
|
//
|
|
// Metrics and pprof are not in this list because they live on the admin
|
|
// listener, a separate port precisely so it can be firewalled rather than
|
|
// authenticated.
|
|
func isExempt(path string, exempt []string) bool {
|
|
for _, e := range exempt {
|
|
if path == e {
|
|
return true
|
|
}
|
|
if e != "/" && strings.HasSuffix(e, "/") && strings.HasPrefix(path, e) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeAuthError(w http.ResponseWriter, err error) {
|
|
status, kind, msg := http.StatusUnauthorized, "invalid_request_error", "invalid api key"
|
|
switch {
|
|
case errors.Is(err, ErrMissing):
|
|
msg = "no api key was provided"
|
|
case errors.Is(err, ErrThrottled):
|
|
w.Header().Set("Retry-After", "1")
|
|
status, kind, msg = http.StatusTooManyRequests, "rate_limit_exceeded", "too many unrecognised api keys; slow down"
|
|
case errors.Is(err, ErrExpired):
|
|
msg = "this api key has expired"
|
|
case errors.Is(err, ErrRevoked):
|
|
// Deliberately the same message as an unknown key. Telling a caller
|
|
// that a key exists but was revoked confirms the key was real.
|
|
msg = "invalid api key"
|
|
case errors.Is(err, ErrInvalid):
|
|
default:
|
|
// A store failure is ours, not the caller's, and must not read as a
|
|
// rejected credential — that would send someone hunting for a key
|
|
// problem that does not exist.
|
|
status, kind, msg = http.StatusServiceUnavailable, "server_error",
|
|
"authentication is temporarily unavailable"
|
|
}
|
|
|
|
if status == http.StatusUnauthorized {
|
|
// What the OpenAI SDKs look for to stop retrying.
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="llmgw"`)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"message": msg, "type": kind, "code": nil,
|
|
}})
|
|
}
|
|
|
|
// WriteForbiddenModel reports a model this key may not use.
|
|
//
|
|
// Separate from authentication because it happens later: the model is only
|
|
// known once the body has been parsed.
|
|
func WriteForbiddenModel(w http.ResponseWriter, model string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
|
"message": "this api key is not permitted to use model " + model,
|
|
"type": "invalid_request_error", "code": nil,
|
|
}})
|
|
}
|
|
|
|
// clientAddr is the address a request came from: through a trusted proxy,
|
|
// the client's.
|
|
func clientAddr(r *http.Request) string { return clientip.Addr(r) }
|