Add custom token support
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user