Add custom token support
This commit is contained in:
+211
-17
@@ -3,6 +3,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -28,10 +31,39 @@ var (
|
||||
ErrExists = errors.New("a token with that name already exists")
|
||||
)
|
||||
|
||||
// Key derivation kinds. A generated token is 256 bits of randomness, so a
|
||||
// plain digest is all it needs: there is no smaller space to search than the
|
||||
// key space itself. A chosen one is a passphrase, and passphrases are guessable
|
||||
// and reused elsewhere, so those get a deliberately slow derivation.
|
||||
const (
|
||||
KDFSHA256 = "sha256" // implied when the field is absent
|
||||
KDFPBKDF2 = "pbkdf2-sha256"
|
||||
|
||||
// PBKDF2Iterations follows the current OWASP guidance for PBKDF2-HMAC-SHA256.
|
||||
PBKDF2Iterations = 600_000
|
||||
minIterations = 100_000
|
||||
|
||||
// MinChosenLength is the floor for a token someone picks themselves.
|
||||
// Shorter than this and the throttle on failed logins is the only thing
|
||||
// standing between a guesser and the account.
|
||||
MinChosenLength = 4
|
||||
maxTokenLength = 256
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenTooShort = fmt.Errorf("a chosen token must be at least %d characters", MinChosenLength)
|
||||
ErrTokenTooLong = fmt.Errorf("a token must be at most %d characters", maxTokenLength)
|
||||
)
|
||||
|
||||
// Token is one named credential. The pointer fields distinguish "not set, so
|
||||
// inherit the server default" from "set to zero, meaning unlimited".
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
|
||||
// KDF is empty for a generated token and KDFPBKDF2 for a chosen one.
|
||||
KDF string `json:"kdf,omitempty"`
|
||||
Salt string `json:"salt,omitempty"`
|
||||
Iter int `json:"iter,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
MaxSize *string `json:"max_size,omitempty"`
|
||||
MaxExpiry *string `json:"max_expiry,omitempty"`
|
||||
@@ -43,6 +75,30 @@ type Token struct {
|
||||
maxSize *int64
|
||||
maxExpiry *time.Duration
|
||||
defaultExpiry *time.Duration
|
||||
salt []byte
|
||||
}
|
||||
|
||||
// Chosen reports whether this credential is a passphrase somebody picked
|
||||
// rather than a generated secret.
|
||||
func (t *Token) Chosen() bool { return t.KDF == KDFPBKDF2 }
|
||||
|
||||
// Verify checks a presented secret against this token.
|
||||
func (t *Token) Verify(secret string) bool {
|
||||
if secret == "" || len(secret) > maxTokenLength {
|
||||
return false
|
||||
}
|
||||
switch t.KDF {
|
||||
case "", KDFSHA256:
|
||||
return EqualHash(t.Hash, HashSecret(secret))
|
||||
case KDFPBKDF2:
|
||||
sum, err := pbkdf2.Key(sha256.New, secret, t.salt, t.Iter, sha256.Size)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return EqualHash(t.Hash, hex.EncodeToString(sum))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolve parses the human-written limit strings once, at load time, so a
|
||||
@@ -54,6 +110,23 @@ func (t *Token) resolve() error {
|
||||
if _, err := hex.DecodeString(t.Hash); err != nil || len(t.Hash) != sha256.Size*2 {
|
||||
return fmt.Errorf("token %q: hash is not a sha256 hex digest", t.Name)
|
||||
}
|
||||
switch t.KDF {
|
||||
case "", KDFSHA256:
|
||||
if t.Salt != "" || t.Iter != 0 {
|
||||
return fmt.Errorf("token %q: salt and iter belong only to %s", t.Name, KDFPBKDF2)
|
||||
}
|
||||
case KDFPBKDF2:
|
||||
salt, err := hex.DecodeString(t.Salt)
|
||||
if err != nil || len(salt) < 16 {
|
||||
return fmt.Errorf("token %q: salt must be at least 16 random bytes in hex", t.Name)
|
||||
}
|
||||
if t.Iter < minIterations {
|
||||
return fmt.Errorf("token %q: iter is %d, below the %d minimum", t.Name, t.Iter, minIterations)
|
||||
}
|
||||
t.salt = salt
|
||||
default:
|
||||
return fmt.Errorf("token %q: unknown kdf %q", t.Name, t.KDF)
|
||||
}
|
||||
if t.MaxSize != nil {
|
||||
n, err := config.ParseSize(*t.MaxSize)
|
||||
if err != nil {
|
||||
@@ -125,10 +198,11 @@ func (t *Token) Limits(c *config.Config) Limits {
|
||||
return l
|
||||
}
|
||||
|
||||
// HashSecret is the one-way transform applied to every secret this service
|
||||
// stores, both API tokens and per-object delete tokens. The secrets are 256-bit
|
||||
// random values, so a plain digest is sufficient - there is nothing to brute
|
||||
// force - and lookup by digest reveals nothing through timing.
|
||||
// HashSecret is the fast one-way transform, used for generated tokens and for
|
||||
// per-object delete tokens. Both are 256-bit random values, so a plain digest
|
||||
// is sufficient - there is nothing to brute force - and lookup by digest
|
||||
// reveals nothing through timing. Chosen passphrases never go through here;
|
||||
// see Token.Verify.
|
||||
func HashSecret(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@@ -139,13 +213,24 @@ func EqualHash(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
// maxVerifyCache bounds the memo below. It is cleared wholesale when full,
|
||||
// which costs one extra derivation per live session and needs no bookkeeping.
|
||||
const maxVerifyCache = 4096
|
||||
|
||||
// File is the token store, backed by a JSON file and reloadable at runtime.
|
||||
type File struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
byHash map[string]*Token
|
||||
byName map[string]*Token
|
||||
mu sync.RWMutex
|
||||
byHash map[string]*Token // generated tokens, found in one step
|
||||
byName map[string]*Token
|
||||
chosen []*Token // passphrases, each needing its own derivation
|
||||
|
||||
// verified memoises derivation results, negative ones included, so a
|
||||
// passphrase costs its full price once rather than on every request.
|
||||
// Cleared whenever the file is reloaded.
|
||||
verified map[string]*Token
|
||||
|
||||
modTime time.Time
|
||||
size int64
|
||||
}
|
||||
@@ -153,7 +238,12 @@ type File struct {
|
||||
// Load reads the token file. A missing file is not an error: the service simply
|
||||
// starts with no credentials and only the anonymous tier available.
|
||||
func Load(path string) (*File, error) {
|
||||
f := &File{path: path, byHash: map[string]*Token{}, byName: map[string]*Token{}}
|
||||
f := &File{
|
||||
path: path,
|
||||
byHash: map[string]*Token{},
|
||||
byName: map[string]*Token{},
|
||||
verified: map[string]*Token{},
|
||||
}
|
||||
if err := f.Reload(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -198,17 +288,23 @@ func (f *File) Reload() error {
|
||||
}
|
||||
byHash := make(map[string]*Token, len(tokens))
|
||||
byName := make(map[string]*Token, len(tokens))
|
||||
var chosen []*Token
|
||||
for _, t := range tokens {
|
||||
if _, dup := byName[t.Name]; dup {
|
||||
return fmt.Errorf("%s: duplicate token name %q", f.path, t.Name)
|
||||
}
|
||||
byHash[t.Hash] = t
|
||||
byName[t.Name] = t
|
||||
if t.Chosen() {
|
||||
chosen = append(chosen, t)
|
||||
} else {
|
||||
byHash[t.Hash] = t
|
||||
}
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.byHash, f.byName = byHash, byName
|
||||
f.byHash, f.byName, f.chosen = byHash, byName, chosen
|
||||
clear(f.verified)
|
||||
if info != nil {
|
||||
f.modTime, f.size = info.ModTime(), info.Size()
|
||||
} else {
|
||||
@@ -242,19 +338,63 @@ func (f *File) MaybeReload() error {
|
||||
return f.Reload()
|
||||
}
|
||||
|
||||
// Resolved reports whether Lookup can answer for this secret without running a
|
||||
// key derivation. Callers use it to decide whether the work needs rate limiting.
|
||||
func (f *File) Resolved(secret string) bool {
|
||||
if secret == "" {
|
||||
return true
|
||||
}
|
||||
h := HashSecret(secret)
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
if _, ok := f.byHash[h]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := f.verified[h]; ok {
|
||||
return true
|
||||
}
|
||||
return len(f.chosen) == 0 // nothing slow to try, so the answer is already in
|
||||
}
|
||||
|
||||
// Lookup resolves a presented secret to its token, or nil.
|
||||
//
|
||||
// Generated tokens are found by digest in one step. A chosen passphrase has a
|
||||
// salt of its own, so there is no index to look it up in: each candidate has to
|
||||
// be derived and compared. That is why the result is memoised, and why callers
|
||||
// should check Resolved first when the secret came from an untrusted source.
|
||||
func (f *File) Lookup(secret string) *Token {
|
||||
if secret == "" {
|
||||
return nil
|
||||
}
|
||||
h := HashSecret(secret)
|
||||
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
t, ok := f.byHash[h]
|
||||
if !ok || !EqualHash(t.Hash, h) {
|
||||
return nil
|
||||
if t, ok := f.byHash[h]; ok && EqualHash(t.Hash, h) {
|
||||
f.mu.RUnlock()
|
||||
return t
|
||||
}
|
||||
return t
|
||||
if t, ok := f.verified[h]; ok {
|
||||
f.mu.RUnlock()
|
||||
return t
|
||||
}
|
||||
chosen := f.chosen
|
||||
f.mu.RUnlock()
|
||||
|
||||
var found *Token
|
||||
for _, t := range chosen {
|
||||
if t.Verify(secret) {
|
||||
found = t
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
if len(f.verified) >= maxVerifyCache {
|
||||
clear(f.verified)
|
||||
}
|
||||
f.verified[h] = found
|
||||
f.mu.Unlock()
|
||||
return found
|
||||
}
|
||||
|
||||
// List returns the tokens, name-sorted, for the CLI.
|
||||
@@ -280,7 +420,12 @@ func (f *File) Add(t *Token) error {
|
||||
return ErrExists
|
||||
}
|
||||
f.byName[t.Name] = t
|
||||
f.byHash[t.Hash] = t
|
||||
if t.Chosen() {
|
||||
f.chosen = append(f.chosen, t)
|
||||
} else {
|
||||
f.byHash[t.Hash] = t
|
||||
}
|
||||
clear(f.verified)
|
||||
return f.saveLocked()
|
||||
}
|
||||
|
||||
@@ -294,6 +439,8 @@ func (f *File) Remove(name string) error {
|
||||
}
|
||||
delete(f.byName, name)
|
||||
delete(f.byHash, t.Hash)
|
||||
f.chosen = slices.DeleteFunc(f.chosen, func(c *Token) bool { return c == t })
|
||||
clear(f.verified)
|
||||
return f.saveLocked()
|
||||
}
|
||||
|
||||
@@ -345,3 +492,50 @@ func (f *File) saveLocked() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewGenerated builds a credential from a fresh 256-bit secret, which it also
|
||||
// returns: this is the only time the secret exists.
|
||||
func NewGenerated(name string) (*Token, string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
secret := hex.EncodeToString(b[:])
|
||||
return &Token{Name: name, Hash: HashSecret(secret), Created: now()}, secret, nil
|
||||
}
|
||||
|
||||
// NewChosen builds a credential from a passphrase somebody picked.
|
||||
//
|
||||
// Unlike a generated secret this one is guessable and, realistically, reused
|
||||
// somewhere else, so it is stored under a slow derivation with a salt of its
|
||||
// own. Cracking the file should not hand an attacker a password that opens
|
||||
// something that matters more than this service.
|
||||
func NewChosen(name, secret string) (*Token, error) {
|
||||
switch {
|
||||
case len(secret) < MinChosenLength:
|
||||
return nil, ErrTokenTooShort
|
||||
case len(secret) > maxTokenLength:
|
||||
return nil, ErrTokenTooLong
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum, err := pbkdf2.Key(sha256.New, secret, salt, PBKDF2Iterations, sha256.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Token{
|
||||
Name: name,
|
||||
KDF: KDFPBKDF2,
|
||||
Salt: hex.EncodeToString(salt),
|
||||
Iter: PBKDF2Iterations,
|
||||
Hash: hex.EncodeToString(sum),
|
||||
Created: now(),
|
||||
// Set here as well as in resolve, so a token is usable the moment it is
|
||||
// built rather than only after a round trip through the file.
|
||||
salt: salt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now().UTC().Truncate(time.Second) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -184,3 +185,157 @@ func TestReloadPicksUpChanges(t *testing.T) {
|
||||
t.Error("the newly written token was not picked up")
|
||||
}
|
||||
}
|
||||
|
||||
// --- chosen tokens -------------------------------------------------------
|
||||
|
||||
func TestChosenTokenRoundTrip(t *testing.T) {
|
||||
f := newFile(t)
|
||||
const passphrase = "godot-friends-2026"
|
||||
|
||||
tok, err := NewChosen("thayol", passphrase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Add(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := f.Lookup(passphrase); got == nil || got.Name != "thayol" {
|
||||
t.Fatalf("Lookup(passphrase) = %v", got)
|
||||
}
|
||||
if f.Lookup(passphrase+"x") != nil || f.Lookup("") != nil {
|
||||
t.Error("a wrong passphrase authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
// A chosen passphrase is guessable and probably reused, so the file must not
|
||||
// give it up to anyone who reads it.
|
||||
func TestChosenTokensAreNotStoredUnderAFastDigest(t *testing.T) {
|
||||
f := newFile(t)
|
||||
const passphrase = "correct-horse-battery"
|
||||
|
||||
tok, err := NewChosen("thayol", passphrase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Add(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(f.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(raw)
|
||||
|
||||
if strings.Contains(body, passphrase) {
|
||||
t.Fatal("the passphrase is stored in the clear")
|
||||
}
|
||||
if strings.Contains(body, HashSecret(passphrase)) {
|
||||
t.Fatal("the passphrase is stored under a plain sha256, which a wordlist breaks")
|
||||
}
|
||||
if !strings.Contains(body, KDFPBKDF2) {
|
||||
t.Error("the entry does not record which derivation was used")
|
||||
}
|
||||
if tok.Iter < PBKDF2Iterations {
|
||||
t.Errorf("iter = %d, want at least %d", tok.Iter, PBKDF2Iterations)
|
||||
}
|
||||
}
|
||||
|
||||
// Two people choosing the same passphrase must not produce the same stored
|
||||
// hash, or cracking one would crack both.
|
||||
func TestChosenTokensAreSaltedIndividually(t *testing.T) {
|
||||
a, err := NewChosen("a", "the-same-passphrase")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := NewChosen("b", "the-same-passphrase")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Salt == b.Salt {
|
||||
t.Error("two entries share a salt")
|
||||
}
|
||||
if a.Hash == b.Hash {
|
||||
t.Error("the same passphrase produced the same hash under two entries")
|
||||
}
|
||||
if !a.Verify("the-same-passphrase") || !b.Verify("the-same-passphrase") {
|
||||
t.Error("a salted entry does not verify its own passphrase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChosenTokenLengthIsEnforced(t *testing.T) {
|
||||
// Derived from the constant rather than written out, so tuning the floor
|
||||
// stays a one-line change instead of a puzzle about which literals moved.
|
||||
for _, short := range []string{"", strings.Repeat("a", MinChosenLength-1)} {
|
||||
if _, err := NewChosen("n", short); err != ErrTokenTooShort {
|
||||
t.Errorf("NewChosen(%d chars) = %v, want ErrTokenTooShort", len(short), err)
|
||||
}
|
||||
}
|
||||
if _, err := NewChosen("n", strings.Repeat("a", MinChosenLength)); err != nil {
|
||||
t.Errorf("a token at the minimum length was refused: %v", err)
|
||||
}
|
||||
if _, err := NewChosen("n", strings.Repeat("a", 300)); err != ErrTokenTooLong {
|
||||
t.Error("an absurdly long token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Generated tokens must keep the cheap path: they are 256-bit random values,
|
||||
// so a derivation would buy nothing and cost a great deal.
|
||||
func TestGeneratedTokensStayOnTheFastPath(t *testing.T) {
|
||||
f := newFile(t)
|
||||
tok, secret, err := NewGenerated("script")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Chosen() {
|
||||
t.Error("a generated token was marked as chosen")
|
||||
}
|
||||
if tok.KDF != "" || tok.Salt != "" {
|
||||
t.Error("a generated token carries derivation parameters it does not need")
|
||||
}
|
||||
if err := f.Add(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.Resolved(secret) {
|
||||
t.Error("a generated token needs slow work to resolve")
|
||||
}
|
||||
if got := f.Lookup(secret); got == nil || got.Name != "script" {
|
||||
t.Fatalf("Lookup = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved is what lets the server decide whether to charge for the work, so
|
||||
// it has to be honest in both directions.
|
||||
func TestResolvedTracksWhatIsMemoised(t *testing.T) {
|
||||
f := newFile(t)
|
||||
const passphrase = "a-chosen-passphrase"
|
||||
tok, err := NewChosen("thayol", passphrase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Add(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if f.Resolved(passphrase) {
|
||||
t.Error("an underived passphrase reported as already resolved")
|
||||
}
|
||||
f.Lookup(passphrase)
|
||||
if !f.Resolved(passphrase) {
|
||||
t.Error("a derived passphrase was not memoised")
|
||||
}
|
||||
|
||||
// Negative results are memoised too, so repeated junk stays cheap.
|
||||
f.Lookup("junk-that-is-wrong")
|
||||
if !f.Resolved("junk-that-is-wrong") {
|
||||
t.Error("a failed derivation was not memoised")
|
||||
}
|
||||
|
||||
// Reloading invalidates the memo, since the entries may have changed.
|
||||
if err := f.Reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Resolved(passphrase) {
|
||||
t.Error("the memo survived a reload of the token file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (c *Config) Register(s *Set) {
|
||||
|
||||
s.Size(&c.MaxTotalBytes, "max-total-bytes", "", "unlimited",
|
||||
"refuse uploads once stored data exceeds this total")
|
||||
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "1GiB",
|
||||
s.Size(&c.MinFreeBytes, "min-free-bytes", "", "10GiB",
|
||||
"refuse uploads when the filesystem has less free space than this")
|
||||
s.String(&c.TokensPath, "tokens", "", "", "FILE",
|
||||
"token file location (default <data>/tokens.json)")
|
||||
|
||||
@@ -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