Add custom token support

This commit is contained in:
2026-09-13 01:01:48 +02:00
parent 1b77cdd165
commit 48229f15a2
12 changed files with 680 additions and 305 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ var adminSorts = map[string]func(a, b adminObject) int{
}
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
lim, err := s.limitsFor(credential(r))
lim, err := s.limitsFor(r, credential(r))
switch {
case err != nil:
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
+10 -12
View File
@@ -34,7 +34,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
"A delete token or an owning token is required.")
return
}
if !s.authorised(m, presented) {
if !s.authorised(r, m, presented) {
// Only failures are throttled, so a correct token is never delayed.
// The info page is publicly shareable and now carries a credential
// field, which is reason enough not to let it be hammered freely.
@@ -110,27 +110,25 @@ func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []str
}
// authorised reports whether any of the presented secrets may delete m.
func (s *Server) authorised(m *store.Meta, presented []string) bool {
func (s *Server) authorised(r *http.Request, m *store.Meta, presented []string) bool {
for _, secret := range presented {
if s.mayDelete(m, secret) {
if s.mayDelete(r, m, secret) {
return true
}
}
return false
}
// mayDelete checks one secret against the object's delete token first, then
// against the token file.
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
// mayDelete checks one secret against the object's delete token first - which
// is always a generated value, so that comparison is cheap - and only then
// against the token file, which may cost a derivation.
func (s *Server) mayDelete(r *http.Request, m *store.Meta, secret string) bool {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
t := s.tokens.Lookup(secret)
if t == nil {
lim, err := s.limitsFor(r, secret)
if err != nil {
return false
}
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
return lim.Admin || (m.Owner != "" && lim.Name == m.Owner)
}
+2 -2
View File
@@ -40,7 +40,7 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := destination(r.URL.Query().Get("next"))
// Already logged in: say so rather than showing an empty form.
if lim, err := s.limitsFor(cookieCredential(r)); err == nil && !lim.Anonymous() {
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil && !lim.Anonymous() {
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
@@ -70,7 +70,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Only failures are throttled, so logging in normally is never delayed.
lim, err := s.limitsFor(token)
lim, err := s.limitsFor(r, token)
if err != nil {
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.loginFailed(w, r, next, http.StatusTooManyRequests,
+3 -3
View File
@@ -57,7 +57,7 @@ type indexPage struct {
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script.
lim, err := s.limitsFor(cookieCredential(r))
lim, err := s.limitsFor(r, cookieCredential(r))
stale := false
if err != nil {
// The token was revoked or the file was edited; end the session rather
@@ -96,7 +96,7 @@ func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
return
}
lim, err := s.limitsFor(credential(r))
lim, err := s.limitsFor(r, credential(r))
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
@@ -154,7 +154,7 @@ func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Met
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
URL: s.objectURL(r, m.ID),
CanDelete: s.mayDelete(m, credential(r)),
CanDelete: s.mayDelete(r, m, credential(r)),
Error: errMsg,
})
}
+11 -2
View File
@@ -133,13 +133,22 @@ var errBadToken = errors.New("unrecognised token")
// limitsFor resolves the effective permissions for a presented secret. An empty
// secret yields the anonymous tier.
func (s *Server) limitsFor(secret string) (auth.Limits, error) {
//
// Verifying a chosen passphrase costs a deliberately slow key derivation, which
// makes an unverified credential an amplifier: a few requests a second carrying
// junk would keep a core busy. So a request that would need that work has to
// pay for it out of the same budget as a failed login. The result is memoised,
// so a real session derives once and every later request is a map lookup.
func (s *Server) limitsFor(r *http.Request, secret string) (auth.Limits, error) {
if secret == "" {
return auth.Anonymous(s.cfg), nil
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
if !s.tokens.Resolved(secret) && !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
return auth.Limits{}, errBadToken
}
t := s.tokens.Lookup(secret)
if t == nil {
return auth.Limits{}, errBadToken
@@ -193,7 +202,7 @@ type page struct {
// who is logged in and offer only the links they can use.
func (s *Server) page(r *http.Request, title string, script bool) page {
p := page{Base: s.cfg.BasePath, Title: title, Script: script}
if lim, err := s.limitsFor(cookieCredential(r)); err == nil {
if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil {
p.User, p.Admin = lim.Name, lim.Admin
}
return p
+105
View File
@@ -1624,3 +1624,108 @@ func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) {
t.Error("the form does not carry the server-rendered size limit")
}
}
// A chosen passphrase has to work everywhere a generated token does: at the
// login form, on an upload, and as a session.
func TestChosenPassphraseWorksEndToEnd(t *testing.T) {
h := newHarness(t, nil)
const passphrase = "godot-friends-2026"
tok, err := auth.NewChosen("memorable", passphrase)
if err != nil {
t.Fatal(err)
}
tok.AllowVanity = true
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
resp := h.postForm(t, "/login", url.Values{"token": {passphrase}, "persist": {"1"}}, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("login with a passphrase => %s", resp.Status)
}
cookie := findCookie(resp, tokenCookie)
if cookie == nil {
t.Fatal("no session was started")
}
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("x"))
req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "chosen-upload")
req.AddCookie(cookie)
up, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
if up.StatusCode != http.StatusCreated {
t.Fatalf("upload with a passphrase session => %s", up.Status)
}
if res := decode[uploadResult](t, up); res.ID != "chosen-upload" {
t.Errorf("id = %q, want chosen-upload", res.ID)
}
}
// Deriving a passphrase is expensive by design, which makes an unverified
// credential an amplifier unless the work is charged for. Junk must not be
// able to buy unlimited derivations.
func TestUnverifiedCredentialsCannotForceUnlimitedDerivations(t *testing.T) {
h := newHarness(t, nil)
tok, err := auth.NewChosen("memorable", "a-chosen-passphrase")
if err != nil {
t.Fatal(err)
}
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
h.authLimiter = newLimiter(1, 3)
// Distinct junk on every request, so the memo never answers.
for i := range 6 {
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: fmt.Sprintf("junk-%d", i)})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// The page still renders; it just renders as anonymous.
if resp.StatusCode != http.StatusOK {
t.Fatalf("page %d => %s", i, resp.Status)
}
}
if h.tokens.Resolved("junk-5") {
t.Error("a derivation ran past the budget")
}
}
// The memo means a live session pays the derivation once, not per request.
func TestPassphraseSessionsAreMemoised(t *testing.T) {
h := newHarness(t, nil)
const passphrase = "a-chosen-passphrase"
tok, err := auth.NewChosen("memorable", passphrase)
if err != nil {
t.Fatal(err)
}
if err := h.tokens.Add(tok); err != nil {
t.Fatal(err)
}
if h.tokens.Resolved(passphrase) {
t.Fatal("resolved before anything verified it")
}
resp := h.get(t, "/", "")
resp.Body.Close()
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: passphrase})
first, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
first.Body.Close()
if !h.tokens.Resolved(passphrase) {
t.Error("the session was not memoised, so every request would derive again")
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ func filenameFromDisposition(h string) string {
func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadRequest, body io.Reader, ip string) {
now := s.now()
lim, err := s.limitsFor(req.token)
lim, err := s.limitsFor(r, req.token)
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return