diff --git a/auth_server/main.go b/auth_server/main.go index 9a229de0..5ea363e8 100644 --- a/auth_server/main.go +++ b/auth_server/main.go @@ -19,7 +19,9 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "flag" + "io/ioutil" "math/rand" "net" "net/http" @@ -104,6 +106,23 @@ func ServeOnce(c *server.Config, cf string) (*server.AuthServer, *http.Server) { tlsConfig.CipherSuites = append(tlsConfig.CipherSuites, s.ID) } } + if c.Server.ClientAuth != "" { + value, found := server.ClientAuthValues[c.Server.ClientAuth] + if !found { + value = tls.NoClientCert + } + tlsConfig.ClientAuth = value + glog.Infof("TLS ClientAuth: %s", tlsConfig.ClientAuth) + } + if c.Server.ClientCA != "" { + pool := x509.NewCertPool() + caFile, err := ioutil.ReadFile(c.Server.ClientCA) + if err != nil { + glog.Exitf("Failed to load client CA file: %v", err) + } + pool.AppendCertsFromPEM(caFile) + tlsConfig.ClientCAs = pool + } if c.Server.CertFile != "" || c.Server.KeyFile != "" { // Check for partial configuration. if c.Server.CertFile == "" || c.Server.KeyFile == "" { diff --git a/auth_server/server/config.go b/auth_server/server/config.go index 866f65af..17ab1ccc 100644 --- a/auth_server/server/config.go +++ b/auth_server/server/config.go @@ -33,25 +33,33 @@ import ( "github.com/cesanta/docker_auth/auth_server/authz" ) +const ( + sourceHeader = "header" + sourceCN = "cn" + sourceStatic = "static" +) + type Config struct { - Server ServerConfig `yaml:"server"` - Token TokenConfig `yaml:"token"` - Users map[string]*authn.Requirements `yaml:"users,omitempty"` - GoogleAuth *authn.GoogleAuthConfig `yaml:"google_auth,omitempty"` - GitHubAuth *authn.GitHubAuthConfig `yaml:"github_auth,omitempty"` - OIDCAuth *authn.OIDCAuthConfig `yaml:"oidc_auth,omitempty"` - GitlabAuth *authn.GitlabAuthConfig `yaml:"gitlab_auth,omitempty"` - LDAPAuth *authn.LDAPAuthConfig `yaml:"ldap_auth,omitempty"` - MongoAuth *authn.MongoAuthConfig `yaml:"mongo_auth,omitempty"` - XormAuthn *authn.XormAuthnConfig `yaml:"xorm_auth,omitempty"` - ExtAuth *authn.ExtAuthConfig `yaml:"ext_auth,omitempty"` - PluginAuthn *authn.PluginAuthnConfig `yaml:"plugin_authn,omitempty"` - ACL authz.ACL `yaml:"acl,omitempty"` - ACLMongo *authz.ACLMongoConfig `yaml:"acl_mongo,omitempty"` - ACLXorm *authz.XormAuthzConfig `yaml:"acl_xorm,omitempty"` - ExtAuthz *authz.ExtAuthzConfig `yaml:"ext_authz,omitempty"` - PluginAuthz *authz.PluginAuthzConfig `yaml:"plugin_authz,omitempty"` - CasbinAuthz *authz.CasbinAuthzConfig `yaml:"casbin_authz,omitempty"` + Server ServerConfig `yaml:"server"` + Token TokenConfig `yaml:"token"` + Users map[string]*authn.Requirements `yaml:"users,omitempty"` + GoogleAuth *authn.GoogleAuthConfig `yaml:"google_auth,omitempty"` + GitHubAuth *authn.GitHubAuthConfig `yaml:"github_auth,omitempty"` + OIDCAuth *authn.OIDCAuthConfig `yaml:"oidc_auth,omitempty"` + GitlabAuth *authn.GitlabAuthConfig `yaml:"gitlab_auth,omitempty"` + LDAPAuth *authn.LDAPAuthConfig `yaml:"ldap_auth,omitempty"` + MongoAuth *authn.MongoAuthConfig `yaml:"mongo_auth,omitempty"` + XormAuthn *authn.XormAuthnConfig `yaml:"xorm_auth,omitempty"` + ExtAuth *authn.ExtAuthConfig `yaml:"ext_auth,omitempty"` + PluginAuthn *authn.PluginAuthnConfig `yaml:"plugin_authn,omitempty"` + ACL authz.ACL `yaml:"acl,omitempty"` + ACLMongo *authz.ACLMongoConfig `yaml:"acl_mongo,omitempty"` + ACLXorm *authz.XormAuthzConfig `yaml:"acl_xorm,omitempty"` + ExtAuthz *authz.ExtAuthzConfig `yaml:"ext_authz,omitempty"` + PluginAuthz *authz.PluginAuthzConfig `yaml:"plugin_authz,omitempty"` + CasbinAuthz *authz.CasbinAuthzConfig `yaml:"casbin_authz,omitempty"` + ClientCertLabels string `yaml:"client_cert_labels,omitempty"` + AlternateCredentials *AlternateCredentialsConfig `yaml:"alternate_credentials"` } type ServerConfig struct { @@ -62,6 +70,8 @@ type ServerConfig struct { RealIPPos int `yaml:"real_ip_pos,omitempty"` CertFile string `yaml:"certificate,omitempty"` KeyFile string `yaml:"key,omitempty"` + ClientAuth string `yaml:"client_auth_type,omitempty"` + ClientCA string `yaml:"client_ca,omitempty"` HSTS bool `yaml:"hsts,omitempty"` TLSMinVersion string `yaml:"tls_min_version,omitempty"` TLSCurvePreferences []string `yaml:"tls_curve_preferences,omitempty"` @@ -90,6 +100,17 @@ type TokenConfig struct { sigAlg string } +type AlternateCredentialsConfig struct { + Label string `yaml:"label,omitempty"` + Username *CredentialSourceConfig `yaml:"username,omitempty"` + Password *CredentialSourceConfig `yaml:"password,omitempty"` +} + +type CredentialSourceConfig struct { + Source string `yaml:"source,omitempty"` + Value string `yaml:"value,omitempty"` +} + // TLSCipherSuitesValues maps CipherSuite names as strings to the actual values // in the crypto/tls package // Taken from https://golang.org/pkg/crypto/tls/#pkg-constants @@ -149,6 +170,24 @@ var TLSCurveIDValues = map[string]tls.CurveID{ "X25519": tls.X25519, } +var ClientAuthValues = map[string]tls.ClientAuthType{ + "NoClientCert": tls.NoClientCert, + "RequestClientCert": tls.RequestClientCert, + "RequireAndVerifyClientCert": tls.RequireAndVerifyClientCert, + "RequireAnyClientCert": tls.RequireAnyClientCert, + "VerifyClientCertIfGiven": tls.VerifyClientCertIfGiven, +} + +func (c *CredentialSourceConfig) validate() error { + if c.Source != sourceHeader && c.Source != sourceCN && c.Source != sourceStatic { + return fmt.Errorf("invalid source: %s", c.Source) + } + if (c.Source == sourceHeader || c.Source == sourceStatic) && c.Value == "" { + return errors.New("value should not be empty") + } + return nil +} + func validate(c *Config) error { if c.Server.ListenAddress == "" { return errors.New("server.addr is required") @@ -334,6 +373,18 @@ func validate(c *Config) error { return fmt.Errorf("bad plugin_authz config: %s", err) } } + + if c.AlternateCredentials != nil { + if c.AlternateCredentials.Username == nil || c.AlternateCredentials.Password == nil { + return errors.New("both username and password must be specified") + } + if err := c.AlternateCredentials.Username.validate(); err != nil { + return err + } + if err := c.AlternateCredentials.Password.validate(); err != nil { + return err + } + } return nil } diff --git a/auth_server/server/server.go b/auth_server/server/server.go index 2592b533..e25936c7 100644 --- a/auth_server/server/server.go +++ b/auth_server/server/server.go @@ -263,6 +263,24 @@ func (as *AuthServer) ParseRequest(req *http.Request) (*authRequest, error) { ar.Password = api.PasswordString(password) } } + + if as.config.AlternateCredentials != nil { + var altCredsSuccess bool + username := alternateCredentials(req, as.config.AlternateCredentials.Username) + password := alternateCredentials(req, as.config.AlternateCredentials.Password) + if username != "" && password != "" && username == ar.User { + ar.User = username + ar.Password = api.PasswordString(password) + altCredsSuccess = true + } + if altCredsSuccess && as.config.AlternateCredentials.Label != "" { + if ar.Labels == nil { + ar.Labels = make(api.Labels, 1) + } + ar.Labels[as.config.AlternateCredentials.Label] = []string{fmt.Sprint(altCredsSuccess)} + } + } + ar.Account = req.FormValue("account") if ar.Account == "" { ar.Account = ar.User @@ -273,6 +291,16 @@ func (as *AuthServer) ParseRequest(req *http.Request) (*authRequest, error) { if err := req.ParseForm(); err != nil { return nil, fmt.Errorf("invalid form value") } + + if as.config.ClientCertLabels != "" && req.TLS != nil && len(req.TLS.PeerCertificates) > 0 { + if ar.Labels == nil { + ar.Labels = make(api.Labels, 3) + } + clientCert := req.TLS.PeerCertificates[0] + ar.Labels[as.config.ClientCertLabels+"_O"] = clientCert.Subject.Organization + ar.Labels[as.config.ClientCertLabels+"_OU"] = clientCert.Subject.OrganizationalUnit + ar.Labels[as.config.ClientCertLabels+"_DNS_NAMES"] = clientCert.DNSNames + } // https://github.com/docker/distribution/blob/1b9ab303a477ded9bdd3fc97e9119fa8f9e58fca/docs/spec/auth/scope.md#resource-scope-grammar if req.FormValue("scope") != "" { for _, scopeValue := range req.Form["scope"] { @@ -311,6 +339,19 @@ func (as *AuthServer) ParseRequest(req *http.Request) (*authRequest, error) { return ar, nil } +func alternateCredentials(r *http.Request, config *CredentialSourceConfig) string { + switch config.Source { + case sourceHeader: + return r.Header.Get(config.Value) + case sourceStatic: + return config.Value + case sourceCN: + if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + return r.TLS.PeerCertificates[0].Subject.CommonName + } + } + return "" +} func (as *AuthServer) Authenticate(ar *authRequest) (bool, api.Labels, error) { for i, a := range as.authenticators { result, labels, err := a.Authenticate(ar.Account, ar.Password) @@ -493,7 +534,7 @@ func (as *AuthServer) doAuth(rw http.ResponseWriter, req *http.Request) { http.Error(rw, "Auth failed.", http.StatusUnauthorized) return } - ar.Labels = labels + ar.Labels = mergeLabels(ar.Labels, labels) } if len(ar.Scopes) > 0 { ares, err = as.Authorize(ar) @@ -521,6 +562,18 @@ func (as *AuthServer) doAuth(rw http.ResponseWriter, req *http.Request) { rw.Write(result) } +func mergeLabels(dest api.Labels, src api.Labels) api.Labels { + if dest == nil { + return src + } else if src == nil { + return dest + } + for k, v := range src { + dest[k] = v + } + return dest +} + func (as *AuthServer) Stop() { for _, an := range as.authenticators { an.Stop() diff --git a/examples/client_certificates.yml b/examples/client_certificates.yml new file mode 100644 index 00000000..2c026b88 --- /dev/null +++ b/examples/client_certificates.yml @@ -0,0 +1,43 @@ +--- +server: + addr: :5001 + certificate: /path/to/server.pem + key: /path/to/server.key + client_ca: /path/to/ca.crt + client_auth_type: RequireAndVerifyClientCert + +alternate_credentials: + # Add a label indicating if alternate credentials were used. + label: ALTERNATE_CREDENTIALS + username: + source: cn + password: + source: static + # a "sentinel value for use with static auth + value: secret + +users: + user1: + # the hash of "secret" + password: $2y$05$hS3s0Fbh5EMclimhNeCyEeH9VIynngvgDmGO6MbooXxle7S0D5boK + +# Add TLS_* labels +client_cert_labels: TLS + +acl: + - match: {account: "user1"} + actions: ["*"] + comment: User 1 has full access + # We can also require that alternate credentials be used through a label match: + - match: {account: "user1", labels: {ALTERNATE_CREDENTIALS: "true"}} + actions: ["*"] + comment: User 1 has full access + - match: {labels: {TLS_OU: developers}} + actions: ["pull"] + comment: Users whose certificate has the OU "developers" can pull + +# To login with a client certificate: +# +# 1. Configure Docker to use the certificate: https://docs.docker.com/engine/security/certificates/ +# 2. Run docker login. The username must be the same as the certificate CN (or header). +# The password can be anything. diff --git a/examples/reference.yml b/examples/reference.yml index ce741d06..447f56d7 100644 --- a/examples/reference.yml +++ b/examples/reference.yml @@ -26,6 +26,11 @@ server: # Server settings. # Use specific certificate and key. certificate: "/path/to/server.pem" key: "/path/to/server.key" + # Optional CA file for client certificate verification. + client_ca: /path/to/ca.crt + # Optional. Defaults to NoClientCert. + # Values can be found at https://pkg.go.dev/crypto/tls#ClientAuthType + client_auth_type: NoClientCert # # The following optional settings will fine tune TLS configuration to improve security. # Leaving them unset should be just fine for most installations. @@ -459,3 +464,30 @@ ext_authz: plugin_authz: plugin_path: "" +# If not empty, and a client certificate is present, the following +# lables prefixed with the given value, will be added: +# - _O = +# - _OU = +# - _DNS_NAMES = +client_cert_labels: TLS + +# Populate the username/password from the HTTP request +alternate_credentials: + # If not empty, a label with the given name and single value "true" or "false" is added + # indicating whether or not alternate credentials was used. + label: ALTERNATE_CREDENTIALS + username: + # Required + # Choices: + # cn - Client certificate CN. + # header - The value of the header specified in `value`. + # static - The static value in `value`. + source: header + # Required if `source`` is static or header. + value: X-Forwarded-User + # See above for configuration values. + password: + # Required + source: + # Required + value: