diff --git a/auth_server/authn/http_auth.go b/auth_server/authn/http_auth.go new file mode 100644 index 00000000..95f3641f --- /dev/null +++ b/auth_server/authn/http_auth.go @@ -0,0 +1,164 @@ +/* + Copyright 2016 Cesanta Software Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package authn + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/cesanta/docker_auth/auth_server/api" + "github.com/cesanta/glog" +) + +const ( + httpAuthAllow = "allow" + httpAuthDeny = "deny" + httpAuthFailed = "failed" + httpAuthNoMatch = "no_match" + httpAuthMaxErrorBodyBytes = 1024 +) + +type HttpAuthConfig struct { + Url string `yaml:"http_url"` + InsecureSkipVerify bool `yaml:"insecure_tls_skip_verify,omitempty"` + HTTPTimeout time.Duration `yaml:"http_timeout,omitempty"` + DisableKeepAlives bool `yaml:"http_disable_keepalives,omitempty"` + Headers map[string][]string `yaml:"http_headers,omitempty"` +} + +type HttpAuthRequest struct { + User string `json:"user"` + Password string `json:"password"` +} + +type HttpAuthResponse struct { + Result string `json:"result,omitempty"` + Labels api.Labels `json:"labels,omitempty"` +} + +func (c *HttpAuthConfig) Validate() error { + if c.Url == "" { + return fmt.Errorf("http_url is not set") + } + return nil +} + +type httpAuth struct { + cfg *HttpAuthConfig + ctx context.Context + cancel context.CancelFunc + httpClient *http.Client +} + +func NewHttpAuth(cfg *HttpAuthConfig) (*httpAuth, error) { + glog.Infof("HTTP authenticator: %s", cfg.Url) + for k, vals := range cfg.Headers { + glog.V(2).Infof("HTTP authenticator header: %s=%v", k, vals) + } + + // Create a context that can be cancelled when the authenticator is stopped + ctx, cancel := context.WithCancel(context.Background()) + + // Set default timeout if not set + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = 15 * time.Second + } + transport := &http.Transport{ + DisableKeepAlives: cfg.DisableKeepAlives, + DisableCompression: true, + ForceAttemptHTTP2: false, + TLSHandshakeTimeout: cfg.HTTPTimeout, + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}, + } + httpClient := &http.Client{ + Transport: transport, + Timeout: cfg.HTTPTimeout, + } + + return &httpAuth{cfg: cfg, ctx: ctx, cancel: cancel, httpClient: httpClient}, nil +} + +func (ha *httpAuth) Authenticate(user string, password api.PasswordString) (bool, api.Labels, error) { + authReq := HttpAuthRequest{ + User: user, + Password: string(password), + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(&authReq); err != nil { + return false, nil, fmt.Errorf("failed to create JSON payload: %w", err) + } + req, err := http.NewRequestWithContext(ha.ctx, "POST", ha.cfg.Url, &buf) + if err != nil { + return false, nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + // Set custom headers + for k, vals := range ha.cfg.Headers { + for _, v := range vals { + req.Header.Add(k, v) + } + } + resp, err := ha.httpClient.Do(req) + if err != nil { + return false, nil, fmt.Errorf("http request failed: %w", err) + } + defer resp.Body.Close() + // Process http errors + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return false, nil, fmt.Errorf("error: HTTP server responded status code %d", resp.StatusCode) + } + // Limit error body size to avoid potential log flooding + if len(body) > httpAuthMaxErrorBodyBytes { + body = body[:httpAuthMaxErrorBodyBytes] + } + return false, nil, fmt.Errorf("error: HTTP server responded status code %d - %s", resp.StatusCode, body) + } + // Process response + authResp := &HttpAuthResponse{} + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return false, nil, fmt.Errorf("invalid JSON in response body: %w", err) + } + switch authResp.Result { + case httpAuthAllow: + return true, authResp.Labels, nil + case httpAuthDeny: + return false, nil, nil + case httpAuthFailed: + return false, nil, api.WrongPass + case httpAuthNoMatch: + return false, nil, api.NoMatch + default: + return false, nil, fmt.Errorf("unexpected \"result\" value %q in JSON response; expected one of %q, %q, %q, %q", authResp.Result, httpAuthAllow, httpAuthDeny, httpAuthFailed, httpAuthNoMatch) + } +} + +func (ha *httpAuth) Stop() { + ha.cancel() +} + +func (ha *httpAuth) Name() string { + return "http_auth" +} diff --git a/auth_server/server/config.go b/auth_server/server/config.go index 13c610b7..1ee273f4 100644 --- a/auth_server/server/config.go +++ b/auth_server/server/config.go @@ -52,6 +52,7 @@ type Config struct { XormAuthn *authn.XormAuthnConfig `yaml:"xorm_auth,omitempty"` ExtAuth *authn.ExtAuthConfig `yaml:"ext_auth,omitempty"` PluginAuthn *authn.PluginAuthnConfig `yaml:"plugin_authn,omitempty"` + HttpAuth *authn.HttpAuthConfig `yaml:"http_auth,omitempty"` ACL authz.ACL `yaml:"acl,omitempty"` ACLMongo *authz.ACLMongoConfig `yaml:"acl_mongo,omitempty"` ACLXorm *authz.XormAuthzConfig `yaml:"acl_xorm,omitempty"` @@ -180,7 +181,7 @@ func validate(c *Config) error { if c.Token.Expiration <= 0 { return fmt.Errorf("expiration must be positive, got %d", c.Token.Expiration) } - if c.Users == nil && c.ExtAuth == nil && c.GoogleAuth == nil && c.GitHubAuth == nil && c.GitlabAuth == nil && c.OIDCAuth == nil && c.LDAPAuth == nil && c.MongoAuth == nil && c.XormAuthn == nil && c.PluginAuthn == nil { + if c.Users == nil && c.ExtAuth == nil && c.GoogleAuth == nil && c.GitHubAuth == nil && c.GitlabAuth == nil && c.OIDCAuth == nil && c.LDAPAuth == nil && c.MongoAuth == nil && c.XormAuthn == nil && c.PluginAuthn == nil && c.HttpAuth == nil { return errors.New("no auth methods are configured, this is probably a mistake. Use an empty user map if you really want to deny everyone") } if c.MongoAuth != nil { @@ -308,6 +309,11 @@ func validate(c *Config) error { return fmt.Errorf("bad ext_auth config: %s", err) } } + if c.HttpAuth != nil { + if err := c.HttpAuth.Validate(); err != nil { + return fmt.Errorf("bad http_auth config: %s", err) + } + } if c.ACL == nil && c.ACLXorm == nil && c.ACLMongo == nil && c.ExtAuthz == nil && c.PluginAuthz == nil { return errors.New("ACL is empty, this is probably a mistake. Use an empty list if you really want to deny all actions") } diff --git a/auth_server/server/server.go b/auth_server/server/server.go index ae7abd82..c9c91570 100644 --- a/auth_server/server/server.go +++ b/auth_server/server/server.go @@ -88,6 +88,13 @@ func NewAuthServer(c *Config) (*AuthServer, error) { if c.ExtAuth != nil { as.authenticators = append(as.authenticators, authn.NewExtAuth(c.ExtAuth)) } + if c.HttpAuth != nil { + ha, err := authn.NewHttpAuth(c.HttpAuth) + if err != nil { + return nil, err + } + as.authenticators = append(as.authenticators, ha) + } if c.GoogleAuth != nil { ga, err := authn.NewGoogleAuth(c.GoogleAuth) if err != nil { @@ -437,7 +444,7 @@ func (as *AuthServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { case req.URL.Path == path_prefix+"/auth": as.doAuth(rw, req) case req.URL.Path == path_prefix+"/auth/token": - as.doAuth(rw, req) + as.doAuth(rw, req) case req.URL.Path == path_prefix+"/google_auth" && as.ga != nil: as.ga.DoGoogleAuth(rw, req) case req.URL.Path == path_prefix+"/github_auth" && as.gha != nil: diff --git a/examples/reference.yml b/examples/reference.yml index 31de9ee7..b53e5208 100644 --- a/examples/reference.yml +++ b/examples/reference.yml @@ -338,6 +338,37 @@ ext_auth: plugin_authn: plugin_path: "" +# HTTP authentication – Authenticate a user via an HTTPS server. +# Authentication is performed by sending a POST request to an HTTPS server. +# The username and password are included as a JSON payload in the request body: +# {"user": "foo", "password": "bar"} +# The server responds with status code 200 and a JSON object indicating the authentication result: +# Allow: { "result": "allow" } +# Deny: { "result": "deny" } +# Failed: { "result": "failed" } +# No match: { "result": "no_match" } +# The optional "labels" field may contain labels to be passed down to authz, where they can +# be used in matching: +# { "result": "allow", "labels": {"groups": ["admin","trainee"], "project": ["hello-world"]} } +# In case of an error the server should return a non-200 status code and may include +# an error message in the response body. +http_auth: + # The the http server URL + http_url: "https://example.com" + # Set to true to allow insecure tls Optional. + insecure_tls_skip_verify: false + # How long to wait when talking to the http server. Optional. + http_timeout: "10s" + # If true, disables HTTP keep-alives and will only use the connection to the server for a single HTTP request. Optional. + http_disable_keepalives: false + # Additional HTTP Headers. Optional. + http_headers: + Authorization: + - Bearer xyz123 + Cache-Control: + - no-cache + - no-store + # Authorization methods. All are tried, any one returning success is sufficient. # At least one must be configured.