git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / auth / token.go /
internal/auth/token.go · 2.8 KiB · raw
view: Source ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package auth

import (
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"strings"
	"sync"
	"time"

	"gitweb/internal/store"
)

// PATVerifier checks personal access tokens for the non-interactive (CI) auth
// path. A user stores the SHA-256 hashes of their tokens, one per line, in their
// own upspin tree at <user>/gitweb/tokens, and grants read on that file to the
// service. Ownership of the tree is the identity proof; possession of a token
// whose hash appears there authenticates as that user.
//
// File format, one entry per line:
//
//	# a comment
//	<64-hex sha256-of-token>  optional label words
type PATVerifier struct {
	st  store.Store
	ttl time.Duration

	mu    sync.Mutex
	cache map[string]patEntry // user -> parsed token hashes
}

type patEntry struct {
	hashes  [][32]byte
	expires time.Time
}

// NewPATVerifier returns a PATVerifier reading token files through st.
func NewPATVerifier(st store.Store) *PATVerifier {
	return &PATVerifier{st: st, ttl: 60 * time.Second, cache: map[string]patEntry{}}
}

// Verify reports whether token is a valid PAT for user. A missing or unreadable
// tokens file is not an error: it simply yields false (fail closed, no leak).
func (v *PATVerifier) Verify(user, token string) (bool, error) {
	if user == "" || token == "" {
		return false, nil
	}
	hashes, err := v.hashesFor(user)
	if err != nil {
		return false, err
	}
	want := sha256.Sum256([]byte(token))
	match := false
	// Compare against every entry without early exit, to keep the work
	// independent of which (if any) token matched.
	for i := range hashes {
		if subtle.ConstantTimeCompare(want[:], hashes[i][:]) == 1 {
			match = true
		}
	}
	return match, nil
}

func (v *PATVerifier) hashesFor(user string) ([][32]byte, error) {
	now := time.Now()
	v.mu.Lock()
	if e, ok := v.cache[user]; ok && now.Before(e.expires) {
		v.mu.Unlock()
		return e.hashes, nil
	}
	v.mu.Unlock()

	var hashes [][32]byte
	data, err := v.st.Get(user + "/gitweb/tokens")
	switch err {
	case nil:
		hashes = parseTokenFile(data)
	case store.ErrNotFound:
		// No tokens file: cache the empty result so repeated failed logins do
		// not hammer the store.
	default:
		return nil, err
	}

	v.mu.Lock()
	v.cache[user] = patEntry{hashes: hashes, expires: now.Add(v.ttl)}
	v.mu.Unlock()
	return hashes, nil
}

// parseTokenFile extracts the 32-byte token hashes from a tokens file, ignoring
// blank lines, comments, and malformed entries.
func parseTokenFile(data []byte) [][32]byte {
	var out [][32]byte
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		field := line
		if i := strings.IndexAny(line, " \t"); i >= 0 {
			field = line[:i]
		}
		raw, err := hex.DecodeString(field)
		if err != nil || len(raw) != 32 {
			continue
		}
		var h [32]byte
		copy(h[:], raw)
		out = append(out, h)
	}
	return out
}