Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions auth_server/authn/http_auth.go
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 7 additions & 1 deletion auth_server/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down
9 changes: 8 additions & 1 deletion auth_server/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions examples/reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down