243 lines
6.6 KiB
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))
|
|
}
|