proxy-pool/internal/platform/httpsecurity/auth.go

243 lines
6.6 KiB
Go

package httpsecurity
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"net/netip"
"strings"
)
var (
errCredentialRejected = errors.New("credential rejected")
errSourceRejected = errors.New("source rejected")
)
type authenticator interface {
authenticate(*http.Request, string) (string, error)
challenges() []string
}
type noAuthenticator struct{}
func (noAuthenticator) authenticate(*http.Request, string) (string, error) { return "", nil }
func (noAuthenticator) challenges() []string { return nil }
type basicAuthenticator struct {
header string
username string
password string
}
func (auth basicAuthenticator) authenticate(request *http.Request, _ string) (string, error) {
value, ok := singleHeader(request, auth.header)
username, password, parsed := parseBasicCredentials(value)
valid := subtle.ConstantTimeSelect(boolInt(ok && parsed), 1, 0)
valid &= secureEqual(username, auth.username)
valid &= secureEqual(password, auth.password)
if valid != 1 {
return "", errCredentialRejected
}
return "basic:" + auth.username, nil
}
func (basicAuthenticator) challenges() []string { return []string{`Basic realm="proxy-pool"`} }
type tokenAuthenticator struct {
mode string
header string
token string
}
func (auth tokenAuthenticator) authenticate(request *http.Request, _ string) (string, error) {
value, ok := singleHeader(request, auth.header)
if auth.mode == ModeBearer {
value, ok = parseScheme(value, "Bearer", ok)
}
if !ok || secureEqual(value, auth.token) != 1 {
return "", errCredentialRejected
}
return credentialSubject(auth.mode, auth.token), nil
}
func (auth tokenAuthenticator) challenges() []string {
if auth.mode == ModeBearer {
return []string{`Bearer realm="proxy-pool"`}
}
return []string{`ApiKey realm="proxy-pool", header="` + auth.header + `"`}
}
type ipAuthenticator struct{ allowed cidrMatcher }
func (auth ipAuthenticator) authenticate(_ *http.Request, source string) (string, error) {
address, err := netip.ParseAddr(source)
if err != nil || !auth.allowed.match(address) {
return "", errSourceRejected
}
return "source:" + source, nil
}
func (ipAuthenticator) challenges() []string { return nil }
type anyAuthenticator struct{ methods []authenticator }
func (auth anyAuthenticator) authenticate(request *http.Request, source string) (string, error) {
sourceRejected := false
for _, method := range auth.methods {
principal, err := method.authenticate(request, source)
if err == nil {
return principal, nil
}
if errors.Is(err, errSourceRejected) {
sourceRejected = true
}
}
if sourceRejected {
return "", errSourceRejected
}
return "", errCredentialRejected
}
func (auth anyAuthenticator) challenges() []string {
var result []string
for _, method := range auth.methods {
result = append(result, method.challenges()...)
}
return result
}
func buildAuthenticator(authentication Authentication, semantics Semantics) (authenticator, error) {
header := "Authorization"
if semantics == ProxySemantics {
header = "Proxy-Authorization"
}
switch authentication.Mode {
case "", ModeNone:
return noAuthenticator{}, nil
case ModeUsernamePassword:
if authentication.Username == "" || authentication.Password == "" {
return nil, ErrInvalidConfig
}
return basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, nil
case ModeAPIKey:
if !validHeaderName(authentication.Header) || authentication.Token == "" {
return nil, ErrInvalidConfig
}
return tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, nil
case ModeBearer:
if authentication.Token == "" {
return nil, ErrInvalidConfig
}
return tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, nil
case ModeIPWhitelist:
allowed, err := newCIDRMatcher(authentication.CIDRs)
if err != nil || len(authentication.CIDRs) == 0 {
return nil, ErrInvalidConfig
}
return ipAuthenticator{allowed: allowed}, nil
case ModeAny:
if len(authentication.Methods) == 0 {
return nil, ErrInvalidConfig
}
methods := make([]authenticator, 0, len(authentication.Methods))
for _, method := range authentication.Methods {
candidate, err := buildMethod(method, semantics)
if err != nil {
return nil, err
}
methods = append(methods, candidate)
}
return anyAuthenticator{methods: methods}, nil
default:
return nil, ErrInvalidConfig
}
}
func buildMethod(method Method, semantics Semantics) (authenticator, error) {
authentication := Authentication{
Mode: method.Mode, Username: method.Username, Password: method.Password,
Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
}
if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" {
return nil, ErrInvalidConfig
}
return buildAuthenticator(authentication, semantics)
}
func parseBasicCredentials(value string) (string, string, bool) {
encoded, ok := parseScheme(value, "Basic", true)
if !ok {
return "", "", false
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
username, password, ok := strings.Cut(string(decoded), ":")
return username, password, ok
}
func parseScheme(value, expected string, present bool) (string, bool) {
if !present {
return "", false
}
fields := strings.Fields(value)
if len(fields) != 2 || !strings.EqualFold(fields[0], expected) {
return "", false
}
return fields[1], true
}
func singleHeader(request *http.Request, name string) (string, bool) {
if request == nil {
return "", false
}
values := request.Header.Values(name)
return first(values), len(values) == 1 && values[0] != ""
}
func first(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
func secureEqual(actual, expected string) int {
actualHash := sha256.Sum256([]byte(actual))
expectedHash := sha256.Sum256([]byte(expected))
return subtle.ConstantTimeCompare(actualHash[:], expectedHash[:])
}
func credentialSubject(kind, credential string) string {
digest := sha256.Sum256([]byte(credential))
return kind + ":" + hex.EncodeToString(digest[:16])
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func validHeaderName(value string) bool {
if value == "" {
return false
}
for _, character := range []byte(value) {
if !isTokenCharacter(character) {
return false
}
}
return true
}
func isTokenCharacter(character byte) bool {
return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(character))
}