feat(auth/csvauth): add Authenticate(user, pass string) to get verified Credential

This commit is contained in:
AJ ONeal 2026-02-26 01:18:25 -07:00
parent 7d35551fa7
commit 01a4cdda8a
No known key found for this signature in database
2 changed files with 53 additions and 19 deletions

View File

@ -9,6 +9,7 @@ import (
"crypto/sha1" "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/csv" "encoding/csv"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash" "hash"
@ -23,6 +24,7 @@ import (
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
var ErrKeySize = errors.New("invalid key size")
var ErrUnauthorized = errors.New("unauthorized") var ErrUnauthorized = errors.New("unauthorized")
var ErrUnknownAlgorithm = errors.New("unknown algorithm") var ErrUnknownAlgorithm = errors.New("unknown algorithm")
var ErrLockedCredential = errors.New("credential is locked") var ErrLockedCredential = errors.New("credential is locked")
@ -83,6 +85,18 @@ func New(aes128key []byte) *Auth {
} }
} }
// MustNewFromHex parses a hex string and uses the first 16 bytes to construct a key
func MustNewFromHex(aes128key string) *Auth {
bytes, err := hex.DecodeString(aes128key)
if err != nil {
panic(err)
}
if len(bytes) < 16 {
panic(fmt.Errorf("%w: %d < 16", ErrKeySize, len(bytes)))
}
return New(bytes)
}
// Load reads a credentials CSV from the given NamedReadCloser (e.g. file, wrapped http request) // Load reads a credentials CSV from the given NamedReadCloser (e.g. file, wrapped http request)
func (a *Auth) LoadCSV(f NamedReadCloser, comma rune) error { func (a *Auth) LoadCSV(f NamedReadCloser, comma rune) error {
csvr := csv.NewReader(f) csvr := csv.NewReader(f)
@ -331,10 +345,11 @@ func (a *Auth) gcmDecrypt(aes128key [16]byte, gcmNonce [12]byte, derived []byte)
return string(plaintext), nil return string(plaintext), nil
} }
// Verify checks Basic Auth credentials, i.e. as decoded from Authorization Basic <base64(user:pass)>. // Authenticate verifies credentials - either Basic Auth style (user/pass) or Bearer Token style.
// It also supports tokens. In short: //
// - if <user>:<pass> and 'user' is found, then "login" credentials // In short:
// - if <token>:"" or <allowed-token-name>:<token>, then "token" credentials // - if <user>:<pass> and 'user' is found, then "login" (basic auth) credentials
// - if <token>:"" or <allowed-token-name>:<token>, then "token" (bearer) credentials
// //
// With a little more nuance and clarity: // With a little more nuance and clarity:
// - if 'user' is found in the "login" credential store, token is NEVER tried // - if 'user' is found in the "login" credential store, token is NEVER tried
@ -342,12 +357,15 @@ func (a *Auth) gcmDecrypt(aes128key [16]byte, gcmNonce [12]byte, derived []byte)
// (because 'pass' is swapped with 'user' when 'pass' is empty) // (because 'pass' is swapped with 'user' when 'pass' is empty)
// - the resulting 'user' must match BasicAuthTokenNames ("", "api", and "apikey" are the defaults) // - the resulting 'user' must match BasicAuthTokenNames ("", "api", and "apikey" are the defaults)
// - then the token is (timing-safe) hashed to check if it exists, and then verified by its algorithm // - then the token is (timing-safe) hashed to check if it exists, and then verified by its algorithm
func (a *Auth) Verify(name, secret string) error { func (a *Auth) Authenticate(name, secret string) (*Credential, error) {
a.mux.Lock() a.mux.Lock()
defer a.mux.Unlock() defer a.mux.Unlock()
c, ok := a.credentials[name] c, ok := a.credentials[name]
if ok { if ok {
return c.Verify(name, secret) if err := c.Verify(name, secret); err != nil {
return nil, err
}
return &c, nil
} }
if secret == "" { if secret == "" {
@ -355,10 +373,16 @@ func (a *Auth) Verify(name, secret string) error {
} }
if slices.Contains(a.BasicAuthTokenNames, name) { if slices.Contains(a.BasicAuthTokenNames, name) {
// this still returns ErrNotFound first // this still returns ErrNotFound first
return a.VerifyToken(secret) return a.loadAndVerifyToken(secret)
} }
return ErrNotFound return nil, ErrNotFound
}
// Same as Login, but without returning the credential
func (a *Auth) Verify(name, secret string) error {
_, err := a.Authenticate(name, secret)
return err
} }
// Verify checks Basic Auth credentials // Verify checks Basic Auth credentials

View File

@ -6,7 +6,24 @@ import (
"encoding/base64" "encoding/base64"
) )
// Provided for consistency. Often better to use Authenticate("", token)
func (a *Auth) LoadToken(secret string) (Credential, error) { func (a *Auth) LoadToken(secret string) (Credential, error) {
var credential Credential
c, err := a.loadAndVerifyToken(secret)
if c != nil {
credential = *c
}
return credential, err
}
// VerifyToken uses a shortened, but timing-safe HMAC to find the token,
// and then verifies it according to the chosen algorithm
func (a *Auth) VerifyToken(secret string) error {
_, err := a.loadAndVerifyToken(secret)
return err
}
func (a *Auth) loadAndVerifyToken(secret string) (*Credential, error) {
hashID := a.tokenCacheID(secret) hashID := a.tokenCacheID(secret)
a.mux.Lock() a.mux.Lock()
@ -14,28 +31,21 @@ func (a *Auth) LoadToken(secret string) (Credential, error) {
a.mux.Unlock() a.mux.Unlock()
if !ok { if !ok {
return Credential{}, ErrNotFound return nil, ErrNotFound
} }
if c.plain == "" { if c.plain == "" {
var err error var err error
if c.plain, err = a.maybeDecryptCredential(c); err != nil { if c.plain, err = a.maybeDecryptCredential(c); err != nil {
return Credential{}, err return nil, err
} }
} }
if err := c.Verify("", secret); err != nil { if err := c.Verify("", secret); err != nil {
return Credential{}, err return nil, err
} }
return c, nil return &c, nil
}
// VerifyToken uses a short, but timing-safe hash to find the token,
// and then verifies it with HMAC
func (a *Auth) VerifyToken(secret string) error {
_, err := a.LoadToken(secret)
return err
} }
func (a *Auth) tokenCacheID(secret string) string { func (a *Auth) tokenCacheID(secret string) string {