git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / internal / auth / token.go /
internal/auth/token.go · 2.8 KiB · raw
view: SourceMarkdown ☑ force

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 /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 }