From dbd61e35ce4e1e5b6233c31a6ba6844470a9f666 Mon Sep 17 00:00:00 2001 From: "fox.cpp" Date: 2026年4月28日 00:55:37 +0300 Subject: [PATCH 1/5] config/parser: Move from cfgparser --- framework/config/config.go | 2 +- framework/config/module/modconfig.go | 2 +- framework/{cfgparser => config/parser}/env.go | 0 framework/{cfgparser => config/parser}/imports.go | 0 framework/{cfgparser => config/parser}/parse.go | 0 framework/{cfgparser => config/parser}/parse_test.go | 0 internal/msgpipeline/config_test.go | 2 +- maddy.go | 2 +- 8 files changed, 4 insertions(+), 4 deletions(-) rename framework/{cfgparser => config/parser}/env.go (100%) rename framework/{cfgparser => config/parser}/imports.go (100%) rename framework/{cfgparser => config/parser}/parse.go (100%) rename framework/{cfgparser => config/parser}/parse_test.go (100%) diff --git a/framework/config/config.go b/framework/config/config.go index 3dd9911b6..cbef06452 100644 --- a/framework/config/config.go +++ b/framework/config/config.go @@ -21,7 +21,7 @@ package config import ( "fmt" - parser "github.com/foxcpp/maddy/framework/cfgparser" + "github.com/foxcpp/maddy/framework/config/parser" ) type ( diff --git a/framework/config/module/modconfig.go b/framework/config/module/modconfig.go index 443bfebd3..2a9224513 100644 --- a/framework/config/module/modconfig.go +++ b/framework/config/module/modconfig.go @@ -31,8 +31,8 @@ import ( "reflect" "strings" - parser "github.com/foxcpp/maddy/framework/cfgparser" "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/config/parser" "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" "github.com/foxcpp/maddy/framework/module" diff --git a/framework/cfgparser/env.go b/framework/config/parser/env.go similarity index 100% rename from framework/cfgparser/env.go rename to framework/config/parser/env.go diff --git a/framework/cfgparser/imports.go b/framework/config/parser/imports.go similarity index 100% rename from framework/cfgparser/imports.go rename to framework/config/parser/imports.go diff --git a/framework/cfgparser/parse.go b/framework/config/parser/parse.go similarity index 100% rename from framework/cfgparser/parse.go rename to framework/config/parser/parse.go diff --git a/framework/cfgparser/parse_test.go b/framework/config/parser/parse_test.go similarity index 100% rename from framework/cfgparser/parse_test.go rename to framework/config/parser/parse_test.go diff --git a/internal/msgpipeline/config_test.go b/internal/msgpipeline/config_test.go index 24d7e51f8..8fc61060a 100644 --- a/internal/msgpipeline/config_test.go +++ b/internal/msgpipeline/config_test.go @@ -23,7 +23,7 @@ import ( "strings" "testing" - parser "github.com/foxcpp/maddy/framework/cfgparser" + "github.com/foxcpp/maddy/framework/config/parser" "github.com/foxcpp/maddy/framework/exterrors" ) diff --git a/maddy.go b/maddy.go index 5c9381d29..e6fb6654a 100644 --- a/maddy.go +++ b/maddy.go @@ -29,9 +29,9 @@ import ( "sync" "github.com/caddyserver/certmagic" - parser "github.com/foxcpp/maddy/framework/cfgparser" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" + "github.com/foxcpp/maddy/framework/config/parser" "github.com/foxcpp/maddy/framework/config/tls" "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/hooks" From f2c8c801d8a01be54d9bb6cf796050e39e97d6fd Mon Sep 17 00:00:00 2001 From: "fox.cpp" Date: 2026年5月11日 16:02:35 +0300 Subject: [PATCH 2/5] auth/tls: Initial auth.tls implementation --- .mkdocs.yml | 1 + docs/reference/auth/tls.md | 76 +++++++ docs/reference/tls.md | 29 ++- framework/config/tls/server.go | 34 +++ framework/module/auth.go | 29 ++- framework/module/modules/dummy.go | 2 +- go.mod | 2 +- go.sum | 4 + internal/auth/dovecot_sasl/dovecot_sasl.go | 101 ++++++++- internal/auth/external/externalauth.go | 2 +- internal/auth/ldap/ldap.go | 2 +- internal/auth/netauth/netauth.go | 2 +- internal/auth/pam/module.go | 2 +- internal/auth/pass_table/table.go | 2 +- internal/auth/pass_table/table_test.go | 3 +- .../auth/plain_separate/plain_separate.go | 4 +- .../plain_separate/plain_separate_test.go | 10 +- internal/auth/sasl.go | 61 ++++- internal/auth/sasl_test.go | 9 +- internal/auth/shadow/module.go | 2 +- internal/auth/tls/tls.go | 197 ++++++++++++++++ .../endpoint/dovecot_sasld/dovecot_sasl.go | 45 +++- internal/endpoint/imap/imap.go | 15 +- internal/endpoint/smtp/session.go | 17 +- maddy.go | 1 + tests/sasl_external_test.go | 212 ++++++++++++++++++ tests/testdata/tls/init.sh | 40 ++++ 27 files changed, 857 insertions(+), 47 deletions(-) create mode 100644 docs/reference/auth/tls.md create mode 100644 internal/auth/tls/tls.go create mode 100644 tests/sasl_external_test.go create mode 100644 tests/testdata/tls/init.sh diff --git a/.mkdocs.yml b/.mkdocs.yml index 3e90eec17..ebe97935b 100644 --- a/.mkdocs.yml +++ b/.mkdocs.yml @@ -71,6 +71,7 @@ nav: - reference/auth/dovecot_sasl.md - reference/auth/plain_separate.md - reference/auth/netauth.md + - reference/auth/tls.md - reference/config-syntax.md - Integration with software: - third-party/dovecot.md diff --git a/docs/reference/auth/tls.md b/docs/reference/auth/tls.md new file mode 100644 index 000000000..04610e78e --- /dev/null +++ b/docs/reference/auth/tls.md @@ -0,0 +1,76 @@ +# TLS certificate authentication + +`auth.tls` module implements TLS client certificate authentication for the server. It should be +used only if the server is correctly configured to use TLS while requiring client certificates. +If TLS is not used or client certificate is not provided by the client, `auth.tls` will +fail. Though it is possible to use classic username-password authentication as a fallback +by specifying multiple providers using `auth` directive multiple times. + +Example: +``` +smtp ... { + tls { + ... + client_auth verify_if_given + client_ca /path/to/ca.pem + } + auth tls + auth pass_table ... # fallback for clients that do not support TLS client authentication + + ... +} +``` + +## Configuration directives + +``` +auth.tls { + identity_fields san_email cn + ignore_requested_identity no + identity_normalize auto + require_key_usage yes + require_ext_key_usage yes +} +``` + +### identity_fields _field..._ +Default: `san_email cn` + +List of certificate fields to use when extracting client identity. + +Valid values are: `san_email`, `cn`. SAN fields will use corresponding +fields of Subject Alternative Name extension, while `cn` will use Common Name field of +the certificate. PKCS#9 emailAddress field (commonly displayed as EMAILADDRESS or E in subject) +is obsolete and is not supported, SAN email field should be used instead. + +If multiple fields are specified, they will be tried in specified order until a non-empty +value is found. If no non-empty value is found, authentication will fail. + +### ignore_requested_identity _yes|no_ +Default: `no` + +If set to `yes`, the server will ignore the identity requested by the client and will +always use the first non-empty value from `identity_fields` as the client identity. If set to +`no`, the server will use the identity requested by the client if it is present in the certificate +and is non-empty. + +Most clients do not support SASL authorization identity and therefore cannot +request a specific identity to be used. + +### identity_normalize _func_ +Default: `auto` + +Function used to normalize the extracted identity and requested identity. See +[Global configuration](../global-config) for details on available functions. + +### require_key_usage _yes|no_ +Default: `yes` + +If set to `yes`, the server will require that the certificate has Key Usage extension with +Digital Signature bit set. + +### require_ext_key_usage _yes|no_ +Default: `yes` + +If set to `yes`, the server will require that the certificate has Extended Key Usage extension +with Client Authentication bit set. diff --git a/docs/reference/tls.md b/docs/reference/tls.md index 954b0e066..eb26774fd 100644 --- a/docs/reference/tls.md +++ b/docs/reference/tls.md @@ -21,6 +21,8 @@ tls { protocols tls1.2 tls1.3 curves X25519 ciphers ... + client_auth none + client_ca ... } ``` @@ -30,7 +32,7 @@ tls { E.g. `tls file certA.pem keyA.pem certB.pem keyB.pem`. If multiple certificates are listed, SNI will be used. - `acme` – Automatically obtains a certificate using ACME protocol (Let's Encrypt) -- `off` – Not really a loader but a special value for tls directive, +- `off` – Not really a loader but a special value for tls directive, explicitly disables TLS for endpoint(s). ## Advanced TLS configuration @@ -49,7 +51,7 @@ Valid values are: `tls1.0`, `tls1.1`, `tls1.2`, `tls1.3` --- -### ciphers _ciphers..._ +### ciphers _ciphers..._ Default: Go version-defined set of 'secure ciphers', ordered by hardware performance @@ -90,6 +92,29 @@ order. Valid values: `p256`, `p384`, `p521`, `X25519`. +### client_auth _mode_ +Default: `none` + +Client authentication mode. If set to `require`, client must present a valid certificate +signed by one of the CAs specified in `client_ca` directive. If set to `verify_if_given`, +client may present a certificate, but it is not required. + +Valid values: `none`, `request`, `require_any`, `verify_if_given`, `require` + +Note that only `require` and `verify_if_given` modes actually verify client certificates. `auth.tls` will +work only with these two modes, so if you want to use client certificate authentication, you should choose +one of them. + +TLS client authentication is not widely supported by email clients, so `require` mode should be used only +if you are sure that all your clients support it and are configured to use it. `verify_if_given` mode is a +good choice if you want to allow client authentication for clients that support it, but do not want to break +compatibility with clients that do not support it. + +### client_ca _paths..._ +Default: none + +List of files with PEM-encoded CA certificates to use when verifying client certificates. + ## Client `tls_client` directive allows to customize behavior of TLS client implementation, diff --git a/framework/config/tls/server.go b/framework/config/tls/server.go index 4fe8e8d31..3532007e1 100644 --- a/framework/config/tls/server.go +++ b/framework/config/tls/server.go @@ -20,6 +20,9 @@ package tls import ( "crypto/tls" + "crypto/x509" + "fmt" + "os" "github.com/foxcpp/maddy/framework/config" modconfig "github.com/foxcpp/maddy/framework/config/module" @@ -27,6 +30,14 @@ import ( "github.com/foxcpp/maddy/framework/module" ) +var clientAuthMap = map[string]tls.ClientAuthType{ + "none": tls.NoClientCert, + "request": tls.RequestClientCert, + "require_any": tls.RequireAnyClientCert, + "verify_if_given": tls.VerifyClientCertIfGiven, + "require": tls.RequireAndVerifyClientCert, +} + type TLSConfig struct { loader module.TLSLoader baseCfg *tls.Config @@ -74,6 +85,8 @@ func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSCo SessionTicketsDisabled: true, } + var clientCAPaths []string + var loader module.TLSLoader if len(blockNode.Args)> 0 { if blockNode.Args[0] == "off" { @@ -109,10 +122,31 @@ func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSCo return nil, nil }, TLSCurvesDirective, &baseCfg.CurvePreferences) + config.EnumMapped[tls.ClientAuthType]( + childM, "client_auth", false, false, + clientAuthMap, tls.NoClientCert, &baseCfg.ClientAuth, + ) + + childM.StringList("client_ca", false, false, nil, &clientCAPaths) + if _, err := childM.Process(); err != nil { return nil, err } + if len(clientCAPaths)> 0 { + pool := x509.NewCertPool() + for _, path := range clientCAPaths { + blob, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if !pool.AppendCertsFromPEM(blob) { + return nil, fmt.Errorf("no certificates was loaded from %s", path) + } + } + baseCfg.ClientCAs = pool + } + baseCfg.MinVersion = tlsVersions[0] baseCfg.MaxVersion = tlsVersions[1] log.Debugf("tls: min version: %x, max version: %x", tlsVersions[0], tlsVersions[1]) diff --git a/framework/module/auth.go b/framework/module/auth.go index 6e8d08945..a6af669fa 100644 --- a/framework/module/auth.go +++ b/framework/module/auth.go @@ -18,19 +18,44 @@ along with this program. If not, see . package module -import "errors" +import ( + "crypto/tls" + "errors" + "net" +) // ErrUnknownCredentials should be returned by auth. provider if supplied // credentials are valid for it but are not recognized (e.g. not found in // used DB). var ErrUnknownCredentials = errors.New("unknown credentials") +type ProxiedTLSContext struct { + ValidCert bool + CertUsername string + Cipher string + CipherBits int + PFS string + Version uint16 +} + +type AuthContext struct { + Service string + LocalAddr net.Addr + RemoteAddr net.Addr + TLS *tls.ConnectionState + ProxiedTLS *ProxiedTLSContext // populated instead of TLS if TLS is terminated by upstream and TLS info is available +} + +type ExternalAuth interface { + AuthExternal(ctx *AuthContext, requestedIdentity string) (finalIdentity string, err error) +} + // PlainAuth is the interface implemented by modules providing authentication using // username:password pairs. // // Modules implementing this interface should be registered with "auth." prefix in name. type PlainAuth interface { - AuthPlain(username, password string) error + AuthPlain(ctx *AuthContext, username, password string) error } // PlainUserDB is a local credentials store that can be managed using maddy command diff --git a/framework/module/modules/dummy.go b/framework/module/modules/dummy.go index 7d2051d17..5ee41a445 100644 --- a/framework/module/modules/dummy.go +++ b/framework/module/modules/dummy.go @@ -36,7 +36,7 @@ import ( // and the actual server code (but the latter is kinda pointless). type Dummy struct{ instName string } -func (d *Dummy) AuthPlain(username, _ string) error { +func (d *Dummy) AuthPlain(ctx *module.AuthContext, username, password string) error { return nil } diff --git a/go.mod b/go.mod index d3d6e9971..a6c315538 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/emersion/go-msgauth v0.6.8 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.21.3 - github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba + github.com/foxcpp/go-dovecot-sasl v0.0.0-20260511123641-a448d7c72dc6 github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 diff --git a/go.sum b/go.sum index e5bf2edba..d4be407bc 100644 --- a/go.sum +++ b/go.sum @@ -308,6 +308,10 @@ github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf h1:rmBPY5fr github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba h1:yxQhqX9RQCvECZKBtqwCZoKy/6CLaozDZeWH9Lvndy0= github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260511115826-4abd5f9faccb h1:EZIXlFawFxTweKz1sYPtDpto0g1h8bfep/U0xGlkxF0= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260511115826-4abd5f9faccb/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260511123641-a448d7c72dc6 h1:urtEnd//IMEws/prECei6cmUJiynEkUs5q6flZ0Py4g= +github.com/foxcpp/go-dovecot-sasl v0.0.0-20260511123641-a448d7c72dc6/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E= github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 h1:qUoaaHyrRpQw85ru6VQcC6JowdhrWl7lSbI1zRX1FTM= github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 h1:qheFPDpteiUy7Ym18R68OYenpk85UyKYGkhYTmddSBg= diff --git a/internal/auth/dovecot_sasl/dovecot_sasl.go b/internal/auth/dovecot_sasl/dovecot_sasl.go index 9b30f9248..683f1c20e 100644 --- a/internal/auth/dovecot_sasl/dovecot_sasl.go +++ b/internal/auth/dovecot_sasl/dovecot_sasl.go @@ -19,6 +19,8 @@ along with this program. If not, see . package dovecotsasl import ( + "crypto/tls" + "crypto/x509" "fmt" "net" @@ -136,7 +138,94 @@ func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { return nil } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) certUsername(cert *x509.Certificate) string { + if len(cert.EmailAddresses)> 0 { + return cert.EmailAddresses[0] + } + if cert.Subject.CommonName != "" { + return cert.Subject.CommonName + } + return "" +} + +func (a *Auth) dovecotParams(ctx *module.AuthContext) []dovecotsasl.Parameter { + var result []dovecotsasl.Parameter + result = append(result, dovecotsasl.ParamNoPenalty) + + if ctx.RemoteAddr != nil { + tcpAddr, ok := ctx.RemoteAddr.(*net.TCPAddr) + if ok { + result = append( + result, + dovecotsasl.ParamRemoteIP(tcpAddr.IP), + dovecotsasl.ParamRemotePort(uint16(tcpAddr.Port)), + ) + } + } + if ctx.LocalAddr != nil { + tcpAddr, ok := ctx.LocalAddr.(*net.TCPAddr) + if ok { + result = append( + result, + dovecotsasl.ParamLocalIP(tcpAddr.IP), + dovecotsasl.ParamLocalPort(uint16(tcpAddr.Port)), + ) + } + } + if ctx.TLS != nil && ctx.TLS.HandshakeComplete { + result = append( + result, + dovecotsasl.ParamSecured(dovecotsasl.SecuredTLS), + dovecotsasl.ParamTransport(dovecotsasl.TransportTLS), + dovecotsasl.ParamTLSCipher(tls.CipherSuiteName(ctx.TLS.CipherSuite)), + dovecotsasl.ParamTLSProtocol(ctx.TLS.Version), + ) + if len(ctx.TLS.VerifiedChains)> 0 && ctx.TLS.VerifiedChains[0] != nil { + result = append( + result, + dovecotsasl.ParamValidClientCert, + ) + username := a.certUsername(ctx.TLS.VerifiedChains[0][0]) + if username != "" { + result = append( + result, + dovecotsasl.Parameter("cert_username="+username), + ) + } + } + } + if ctx.ProxiedTLS != nil { + result = append( + result, + dovecotsasl.ParamSecured(dovecotsasl.SecuredTLS), + dovecotsasl.ParamTransport(dovecotsasl.TransportTLS), + ) + if ctx.ProxiedTLS.Cipher != "" { + result = append(result, dovecotsasl.ParamTLSCipher(ctx.ProxiedTLS.Cipher)) + } + if ctx.ProxiedTLS.Version != 0 { + result = append(result, dovecotsasl.ParamTLSProtocol(ctx.ProxiedTLS.Version)) + } + if ctx.ProxiedTLS.PFS != "" { + result = append(result, dovecotsasl.ParamTLSPFS(ctx.ProxiedTLS.PFS)) + } + if ctx.ProxiedTLS.CipherBits != 0 { + result = append(result, dovecotsasl.ParamTLSCipherBits(ctx.ProxiedTLS.CipherBits)) + } + if ctx.ProxiedTLS.CertUsername != "" { + result = append(result, dovecotsasl.Parameter("cert_username="+ctx.ProxiedTLS.CertUsername)) + } + } + + return result +} + +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { + service := "SMTP" + if ctx.Service != "" { + service = ctx.Service + } + if _, ok := a.mechanisms[sasl.Plain]; ok { cl, err := a.getConn() if err != nil { @@ -144,10 +233,8 @@ func (a *Auth) AuthPlain(username, password string) error { } defer a.returnConn(cl) - // Pretend it is SMTPS even though we really don't know. - // We also have no connection information to pass to the server... - return cl.Do("SMTP", sasl.NewPlainClient("", username, password), - dovecotsasl.Secured, dovecotsasl.NoPenalty) + return cl.Do(service, sasl.NewPlainClient("", username, password), + a.dovecotParams(ctx)...) } if _, ok := a.mechanisms[sasl.Login]; ok { cl, err := a.getConn() @@ -156,8 +243,8 @@ func (a *Auth) AuthPlain(username, password string) error { } defer a.returnConn(cl) - return cl.Do("SMTP", sasl.NewLoginClient(username, password), - dovecotsasl.Secured, dovecotsasl.NoPenalty) + return cl.Do(service, sasl.NewLoginClient(username, password), + a.dovecotParams(ctx)...) } return auth.ErrUnsupportedMech diff --git a/internal/auth/external/externalauth.go b/internal/auth/external/externalauth.go index 1d3f4ae31..5a75f9fe4 100644 --- a/internal/auth/external/externalauth.go +++ b/internal/auth/external/externalauth.go @@ -91,7 +91,7 @@ func (ea *ExternalAuth) Configure(inlineArgs []string, cfg *config.Map) error { return nil } -func (ea *ExternalAuth) AuthPlain(username, password string) error { +func (ea *ExternalAuth) AuthPlain(ctx *module.AuthContext, username, password string) error { accountName, ok := auth.CheckDomainAuth(username, ea.perDomain, ea.domains) if !ok { return module.ErrUnknownCredentials diff --git a/internal/auth/ldap/ldap.go b/internal/auth/ldap/ldap.go index af8c63045..e0d50125f 100644 --- a/internal/auth/ldap/ldap.go +++ b/internal/auth/ldap/ldap.go @@ -245,7 +245,7 @@ func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) return userDN, true, nil } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { conn, err := a.getConn() if err != nil { return err diff --git a/internal/auth/netauth/netauth.go b/internal/auth/netauth/netauth.go index ce6715e66..c4a9f6646 100644 --- a/internal/auth/netauth/netauth.go +++ b/internal/auth/netauth/netauth.go @@ -91,7 +91,7 @@ func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error // AuthPlain attempts straightforward authentication of the entity on // the remote NetAuth server. -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { a.log.Debugf("attempting to auth user: %s", username) if err := a.nacl.AuthEntity(context.Background(), username, password); err != nil { return module.ErrUnknownCredentials diff --git a/internal/auth/pam/module.go b/internal/auth/pam/module.go index 12977a52f..619cbad32 100644 --- a/internal/auth/pam/module.go +++ b/internal/auth/pam/module.go @@ -79,7 +79,7 @@ func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { return nil } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { if a.useHelper { if err := external.AuthUsingHelper(a.helperPath, username, password); err != nil { return err diff --git a/internal/auth/pass_table/table.go b/internal/auth/pass_table/table.go index 8d3cea34e..8bf53e918 100644 --- a/internal/auth/pass_table/table.go +++ b/internal/auth/pass_table/table.go @@ -73,7 +73,7 @@ func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error return a.table.Lookup(ctx, key) } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { key, err := precis.UsernameCaseMapped.CompareKey(username) if err != nil { return err diff --git a/internal/auth/pass_table/table_test.go b/internal/auth/pass_table/table_test.go index 7c8cf6149..42bfa4903 100644 --- a/internal/auth/pass_table/table_test.go +++ b/internal/auth/pass_table/table_test.go @@ -23,6 +23,7 @@ import ( "github.com/foxcpp/maddy/framework/config" "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/internal/testutils" ) @@ -51,7 +52,7 @@ func TestAuth_AuthPlain(t *testing.T) { check := func(user, pass string, ok bool) { t.Helper() - err := a.AuthPlain(user, pass) + err := a.AuthPlain(&module.AuthContext{}, user, pass) if (err == nil) != ok { t.Errorf("ok=%v, err: %v", ok, err) } diff --git a/internal/auth/plain_separate/plain_separate.go b/internal/auth/plain_separate/plain_separate.go index ae4371437..ab0117ddf 100644 --- a/internal/auth/plain_separate/plain_separate.go +++ b/internal/auth/plain_separate/plain_separate.go @@ -114,7 +114,7 @@ func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error return "", true, nil } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { ok := len(a.userTbls) == 0 for _, tbl := range a.userTbls { _, tblOk, err := tbl.Lookup(context.TODO(), username) @@ -132,7 +132,7 @@ func (a *Auth) AuthPlain(username, password string) error { var lastErr error for _, p := range a.passwd { - if err := p.AuthPlain(username, password); err != nil { + if err := p.AuthPlain(ctx, username, password); err != nil { lastErr = err continue } diff --git a/internal/auth/plain_separate/plain_separate_test.go b/internal/auth/plain_separate/plain_separate_test.go index e5365cc70..303f2a328 100644 --- a/internal/auth/plain_separate/plain_separate_test.go +++ b/internal/auth/plain_separate/plain_separate_test.go @@ -35,7 +35,7 @@ func (mockAuth) SASLMechanisms() []string { return []string{sasl.Plain, sasl.Login} } -func (m mockAuth) AuthPlain(username, _ string) error { +func (m mockAuth) AuthPlain(ctx *module.AuthContext, username, password string) error { ok := m.db[username] if !ok { return errors.New("invalid creds") @@ -63,7 +63,7 @@ func TestPlainSplit_NoUser(t *testing.T) { }, } - err := a.AuthPlain("user1", "aaa") + err := a.AuthPlain(&module.AuthContext{}, "user1", "aaa") if err != nil { t.Fatal("Unexpected error:", err) } @@ -85,7 +85,7 @@ func TestPlainSplit_NoUser_MultiPass(t *testing.T) { }, } - err := a.AuthPlain("user1", "aaa") + err := a.AuthPlain(&module.AuthContext{}, "user1", "aaa") if err != nil { t.Fatal("Unexpected error:", err) } @@ -114,7 +114,7 @@ func TestPlainSplit_UserPass(t *testing.T) { }, } - err := a.AuthPlain("user1", "aaa") + err := a.AuthPlain(&module.AuthContext{}, "user1", "aaa") if err != nil { t.Fatal("Unexpected error:", err) } @@ -148,7 +148,7 @@ func TestPlainSplit_MultiUser_Pass(t *testing.T) { }, } - err := a.AuthPlain("user1", "aaa") + err := a.AuthPlain(&module.AuthContext{}, "user1", "aaa") if err != nil { t.Fatal("Unexpected error:", err) } diff --git a/internal/auth/sasl.go b/internal/auth/sasl.go index 591e37647..c753005f0 100644 --- a/internal/auth/sasl.go +++ b/internal/auth/sasl.go @@ -22,7 +22,6 @@ import ( "context" "errors" "fmt" - "net" "github.com/emersion/go-sasl" "github.com/foxcpp/maddy/framework/config" @@ -56,12 +55,17 @@ type SASLAuth struct { ErrorMap func(err error) error - Plain []module.PlainAuth + External []module.ExternalAuth + Plain []module.PlainAuth } func (s *SASLAuth) SASLMechanisms() []string { var mechs []string + if len(s.External) != 0 { + mechs = append(mechs, sasl.External) + } + if len(s.Plain) != 0 { mechs = append(mechs, sasl.Plain) if s.EnableLogin { @@ -100,7 +104,26 @@ func (s *SASLAuth) usernameForAuth(ctx context.Context, saslUsername string) (st return mapped, nil } -func (s *SASLAuth) AuthPlain(username, password string) error { +func (s *SASLAuth) AuthExternal(ctx *SASLContext, identity string) (string, error) { + if len(s.External) == 0 { + return "", ErrUnsupportedMech + } + + var lastErr error + for _, e := range s.External { + s.Log.DebugMsg("attempting authentication", "module", e) + + var finalIdentity string + finalIdentity, lastErr = e.AuthExternal((*module.AuthContext)(ctx), identity) + if lastErr == nil { + return finalIdentity, nil + } + } + + return "", fmt.Errorf("no auth. provider succeeded, last err: %w", lastErr) +} + +func (s *SASLAuth) AuthPlain(ctx *module.AuthContext, username, password string) error { if len(s.Plain) == 0 { return ErrUnsupportedMech } @@ -116,7 +139,7 @@ func (s *SASLAuth) AuthPlain(username, password string) error { "mapped_username", mappedUsername, "original_username", username, "module", p) - lastErr = p.AuthPlain(mappedUsername, password) + lastErr = p.AuthPlain(ctx, mappedUsername, password) if lastErr == nil { return nil } @@ -125,6 +148,8 @@ func (s *SASLAuth) AuthPlain(username, password string) error { return fmt.Errorf("no auth. provider accepted creds, last err: %w", lastErr) } +type SASLContext module.AuthContext + type ContextData struct { // Authentication username. May be different from identity. Username string @@ -135,10 +160,24 @@ type ContextData struct { // CreateSASL creates the sasl.Server instance for the corresponding mechanism. func (s *SASLAuth) CreateSASL( - mech string, remoteAddr net.Addr, + mech string, ctx *SASLContext, successCb func(identity string, data ContextData) error, ) sasl.Server { switch mech { + case sasl.External: + return sasl.NewExternalServer(func(identity string) error { + acceptedIdentity, err := s.AuthExternal(ctx, identity) + if err != nil { + s.Log.Error("authentication failed", err, "src_ip", ctx.RemoteAddr) + if s.ErrorMap != nil { + return s.ErrorMap(ErrInvalidAuthCred) + } + return ErrInvalidAuthCred + } + return successCb(acceptedIdentity, ContextData{ + Username: acceptedIdentity, + }) + }) case sasl.Plain: return sasl.NewPlainServer(func(identity, username, password string) error { if identity == "" { @@ -151,9 +190,9 @@ func (s *SASLAuth) CreateSASL( return ErrInvalidAuthCred } - err := s.AuthPlain(username, password) + err := s.AuthPlain((*module.AuthContext)(ctx), username, password) if err != nil { - s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr) + s.Log.Error("authentication failed", err, "username", username, "src_ip", ctx.RemoteAddr) if s.ErrorMap != nil { return s.ErrorMap(ErrInvalidAuthCred) } @@ -179,9 +218,9 @@ func (s *SASLAuth) CreateSASL( return err } - err = s.AuthPlain(username, password) + err = s.AuthPlain((*module.AuthContext)(ctx), username, password) if err != nil { - s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr) + s.Log.Error("authentication failed", err, "username", username, "src_ip", ctx.RemoteAddr) if s.ErrorMap != nil { return s.ErrorMap(ErrInvalidAuthCred) } @@ -210,6 +249,10 @@ func (s *SASLAuth) AddProvider(m *config.Map, node config.Node) error { s.Plain = append(s.Plain, plainAuth) hasAny = true } + if externalAuth, ok := any.(module.ExternalAuth); ok { + s.External = append(s.External, externalAuth) + hasAny = true + } if !hasAny { return config.NodeErr(node, "auth: specified module does not provide any SASL mechanism") diff --git a/internal/auth/sasl_test.go b/internal/auth/sasl_test.go index a59cfc791..b8ad60265 100644 --- a/internal/auth/sasl_test.go +++ b/internal/auth/sasl_test.go @@ -20,7 +20,6 @@ package auth import ( "errors" - "net" "testing" "github.com/foxcpp/maddy/framework/module" @@ -31,7 +30,7 @@ type mockAuth struct { db map[string]bool } -func (m mockAuth) AuthPlain(username, _ string) error { +func (m mockAuth) AuthPlain(ctx *module.AuthContext, username, password string) error { ok := m.db[username] if !ok { return errors.New("invalid creds") @@ -52,7 +51,7 @@ func TestCreateSASL(t *testing.T) { } t.Run("XWHATEVER", func(t *testing.T) { - srv := a.CreateSASL("XWHATEVER", &net.TCPAddr{}, func(string, ContextData) error { return nil }) + srv := a.CreateSASL("XWHATEVER", &SASLContext{}, func(string, ContextData) error { return nil }) _, _, err := srv.Next([]byte("")) if err == nil { t.Error("No error for XWHATEVER use") @@ -60,7 +59,7 @@ func TestCreateSASL(t *testing.T) { }) t.Run("PLAIN", func(t *testing.T) { - srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error { + srv := a.CreateSASL("PLAIN", &SASLContext{}, func(id string, data ContextData) error { if id != "user1" { t.Fatal("Wrong auth. identities passed to callback:", id) } @@ -74,7 +73,7 @@ func TestCreateSASL(t *testing.T) { }) t.Run("PLAIN with authorization identity", func(t *testing.T) { - srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error { + srv := a.CreateSASL("PLAIN", &SASLContext{}, func(id string, data ContextData) error { if id != "user1" { t.Fatal("Wrong authorization identity passed:", id) } diff --git a/internal/auth/shadow/module.go b/internal/auth/shadow/module.go index 8223af28b..8db07fc08 100644 --- a/internal/auth/shadow/module.go +++ b/internal/auth/shadow/module.go @@ -110,7 +110,7 @@ func (a *Auth) Lookup(username string) (string, bool, error) { return "", true, nil } -func (a *Auth) AuthPlain(username, password string) error { +func (a *Auth) AuthPlain(ctx *module.AuthContext, username, password string) error { if a.useHelper { return external.AuthUsingHelper(a.helperPath, username, password) } diff --git a/internal/auth/tls/tls.go b/internal/auth/tls/tls.go new file mode 100644 index 000000000..1acf27464 --- /dev/null +++ b/internal/auth/tls/tls.go @@ -0,0 +1,197 @@ +package tls + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + + "github.com/foxcpp/maddy/framework/config" + "github.com/foxcpp/maddy/framework/container" + "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" + "github.com/foxcpp/maddy/framework/module/modules" + "github.com/foxcpp/maddy/internal/authz" +) + +const modName = "auth.tls" + +type CertIdentityFunc = func(*x509.Certificate) []string + +func CertIdentityCN(cert *x509.Certificate) []string { + return []string{cert.Subject.CommonName} +} + +func CertIdentitySANEmail(cert *x509.Certificate) []string { + return cert.EmailAddresses +} + +var certIdentityFuncs = map[string]CertIdentityFunc{ + "cn": CertIdentityCN, + "san_email": CertIdentitySANEmail, +} + +type Auth struct { + log *log.Logger + instName string + + identityFuncs []CertIdentityFunc + ignoreRequestedIdentity bool + identityNormalize authz.NormalizeFunc + requireKeyUsage bool + requireExtKeyUsage bool +} + +func New(c *container.C, modName, instName string) (module.Module, error) { + return &Auth{ + log: c.DefaultLogger.Sublogger(modName), + instName: instName, + }, nil +} + +func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error { + if len(inlineArgs)> 0 { + return errors.New("inline args not supported") + } + config.EnumListMapped[CertIdentityFunc]( + cfg, "identity_fields", false, false, + certIdentityFuncs, []CertIdentityFunc{CertIdentitySANEmail, CertIdentityCN}, + &a.identityFuncs, + ) + cfg.Bool("ignore_requested_identity", false, false, &a.ignoreRequestedIdentity) + cfg.Bool("require_key_usage", false, true, &a.requireKeyUsage) + cfg.Bool("require_ext_key_usage", false, true, &a.requireExtKeyUsage) + config.EnumMapped[authz.NormalizeFunc]( + cfg, "identity_normalize", false, false, + authz.NormalizeFuncs, authz.NormalizeAuto, + &a.identityNormalize, + ) + if _, err := cfg.Process(); err != nil { + return err + } + + if len(a.identityFuncs) == 0 { + return errors.New("auth.tls: at least one identity field should be specified") + } + + return nil +} + +func (a *Auth) Name() string { + return modName +} + +func (a *Auth) InstanceName() string { + return a.instName +} + +func (a *Auth) identities(cert *x509.Certificate) []string { + result := make([]string, 0, len(a.identityFuncs)) + for _, identityFunc := range a.identityFuncs { + result = append(result, identityFunc(cert)...) + } + return result +} + +func x509Fingerprint(cert *x509.Certificate) string { + hash := sha256.Sum256(cert.Raw) + return hex.EncodeToString(hash[:]) +} + +func (a *Auth) authenticateProxied(ctx *module.AuthContext, requestedIdentity string) (finalIdentity string, err error) { + certUsername, err := a.identityNormalize(ctx.ProxiedTLS.CertUsername) + if err != nil { + return "", fmt.Errorf("auth.tls: failed to normalize certificate name %q: %w", + ctx.ProxiedTLS.CertUsername, err) + } + + if requestedIdentity != "" && !a.ignoreRequestedIdentity { + if requestedIdentity == certUsername { + return certUsername, nil + } + return "", fmt.Errorf("auth.tls: requested identity does not match certificate username") + } + + return certUsername, nil +} + +func (a *Auth) AuthExternal(ctx *module.AuthContext, requestedIdentity string) (finalIdentity string, err error) { + if ctx.ProxiedTLS != nil { + return a.authenticateProxied(ctx, requestedIdentity) + } + + if requestedIdentity != "" { + requestedIdentity, err = a.identityNormalize(requestedIdentity) + if err != nil { + return "", fmt.Errorf("auth.tls: failed to normalize requested identity %q: %w", + requestedIdentity, err) + } + } + + if ctx.TLS == nil || !ctx.TLS.HandshakeComplete { + return "", errors.New("auth.tls: no TLS session to authenticate") + } + if len(ctx.TLS.PeerCertificates) == 0 { + return "", errors.New("auth.tls: no client certificate to authenticate") + } + if len(ctx.TLS.VerifiedChains) == 0 { + return "", errors.New("auth.tls: client certificate is not verified") + } + if len(ctx.TLS.VerifiedChains[0]) == 0 { + return "", errors.New("auth.tls: verified chain is empty") + } + + leafCert := ctx.TLS.VerifiedChains[0][0] + + if a.requireKeyUsage && leafCert.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return "", errors.New("auth.tls: key usage digitalSignature is required") + } + if a.requireExtKeyUsage { + var ok bool + for _, eku := range leafCert.ExtKeyUsage { + if eku == x509.ExtKeyUsageClientAuth { + ok = true + break + } + } + if !ok { + return "", errors.New("auth.tls: no client auth EKU found in certificate") + } + } + + identities := a.identities(leafCert) + if len(identities) == 0 { + return "", errors.New("auth.tls: no client identity in provided certificate") + } + + if requestedIdentity == "" || a.ignoreRequestedIdentity { + if a.log.IsDebug() { + a.log.DebugMsg("accepted client certificate identity", + "identity", requestedIdentity, "cert_sha256", x509Fingerprint(leafCert)) + } + + return identities[0], nil + } + + for _, identity := range identities { + identity, err = a.identityNormalize(identity) + if err != nil { + return "", fmt.Errorf("auth.tls: invalid identity %q: %v", identity, err) + } + + if identity == requestedIdentity { + if a.log.IsDebug() { + a.log.DebugMsg("accepted client certificate identity", + "identity", requestedIdentity, "cert_sha256", x509Fingerprint(leafCert)) + } + + return identity, nil + } + } + return "", errors.New("auth.tls: requested identity is not allowed by provided certificate") +} + +func init() { + modules.Register("auth.tls", New) +} diff --git a/internal/endpoint/dovecot_sasld/dovecot_sasl.go b/internal/endpoint/dovecot_sasld/dovecot_sasl.go index 215b85082..e0d2f45e9 100644 --- a/internal/endpoint/dovecot_sasld/dovecot_sasl.go +++ b/internal/endpoint/dovecot_sasld/dovecot_sasl.go @@ -19,6 +19,7 @@ along with this program. If not, see . package dovecotsasld import ( + "crypto/tls" "fmt" stdlog "log" "net" @@ -31,6 +32,7 @@ import ( modconfig "github.com/foxcpp/maddy/framework/config/module" "github.com/foxcpp/maddy/framework/container" "github.com/foxcpp/maddy/framework/log" + "github.com/foxcpp/maddy/framework/module" "github.com/foxcpp/maddy/framework/module/modules" "github.com/foxcpp/maddy/framework/resource/netresource" "github.com/foxcpp/maddy/internal/auth" @@ -69,6 +71,31 @@ func (endp *Endpoint) InstanceName() string { return modName } +func proxiedTLSData(req *dovecotsasl.AuthReq) *module.ProxiedTLSContext { + var version uint16 + switch req.TLSProtocol { + case "TLSv1.0": + version = tls.VersionTLS10 + case "TLSv1.1": + version = tls.VersionTLS11 + case "TLSv1.2": + version = tls.VersionTLS12 + case "TLSv1.3": + version = tls.VersionTLS13 + default: + version = tls.VersionTLS10 + } + + return &module.ProxiedTLSContext{ + ValidCert: req.ValidClientCert, + CertUsername: req.CertUsername, + Cipher: req.TLSCipher, + CipherBits: req.TLSCipherBits, + PFS: req.TLSPFS, + Version: version, + } +} + func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { cfg.Callback("auth", func(m *config.Map, node config.Node) error { return endp.saslAuth.AddProvider(m, node) @@ -92,7 +119,23 @@ func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { remoteAddr = &net.TCPAddr{IP: req.RemoteIP, Port: int(req.RemotePort)} } - return endp.saslAuth.CreateSASL(mech, remoteAddr, func(_ string, _ auth.ContextData) error { return nil }) + var localAddr net.Addr + if req.LocalIP != nil && req.LocalPort != 0 { + localAddr = &net.TCPAddr{IP: req.LocalIP, Port: int(req.LocalPort)} + } + + var proxiedTLS *module.ProxiedTLSContext + if (req.Secured && req.SecuredMethod == dovecotsasl.SecuredTLS) || + req.Transport == string(dovecotsasl.TransportTLS) { + proxiedTLS = proxiedTLSData(req) + } + + return endp.saslAuth.CreateSASL(mech, &auth.SASLContext{ + Service: req.Service, + LocalAddr: localAddr, + RemoteAddr: remoteAddr, + ProxiedTLS: proxiedTLS, + }, func(_ string, _ auth.ContextData) error { return nil }) }) } diff --git a/internal/endpoint/imap/imap.go b/internal/endpoint/imap/imap.go index afc68a796..87abd88d0 100644 --- a/internal/endpoint/imap/imap.go +++ b/internal/endpoint/imap/imap.go @@ -145,7 +145,13 @@ func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error { for _, mech := range endp.saslAuth.SASLMechanisms() { endp.serv.EnableAuth(mech, func(c imapserver.Conn) sasl.Server { - return endp.saslAuth.CreateSASL(mech, c.Info().RemoteAddr, func(identity string, data auth.ContextData) error { + info := c.Info() + return endp.saslAuth.CreateSASL(mech, &auth.SASLContext{ + Service: "IMAP", + LocalAddr: info.LocalAddr, + RemoteAddr: info.RemoteAddr, + TLS: info.TLS, + }, func(identity string, data auth.ContextData) error { return endp.openAccount(c, identity) }) }) @@ -280,7 +286,12 @@ func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error { func (endp *Endpoint) Login(connInfo *imap.ConnInfo, username, password string) (imapbackend.User, error) { // saslAuth handles AuthMap calling. - err := endp.saslAuth.AuthPlain(username, password) + err := endp.saslAuth.AuthPlain(&module.AuthContext{ + Service: "IMAP", + LocalAddr: connInfo.LocalAddr, + RemoteAddr: connInfo.RemoteAddr, + TLS: connInfo.TLS, + }, username, password) if err != nil { endp.log.Error("authentication failed", err, "username", username, "src_ip", connInfo.RemoteAddr) return nil, imapbackend.ErrInvalidCredentials diff --git a/internal/endpoint/smtp/session.go b/internal/endpoint/smtp/session.go index c6adfa0c3..af92b9e39 100644 --- a/internal/endpoint/smtp/session.go +++ b/internal/endpoint/smtp/session.go @@ -21,6 +21,7 @@ package smtp import ( "bufio" "context" + "crypto/tls" "errors" "fmt" "io" @@ -103,7 +104,17 @@ func (s *Session) AuthMechanisms() []string { } func (s *Session) Auth(mech string) (sasl.Server, error) { - return s.endp.saslAuth.CreateSASL(mech, s.connState.RemoteAddr, func(identity string, data auth.ContextData) error { + var tlsState *tls.ConnectionState + if s.connState.TLS.HandshakeComplete { + tlsState = &s.connState.TLS + } + + return s.endp.saslAuth.CreateSASL(mech, &auth.SASLContext{ + Service: "SMTP", + LocalAddr: s.connState.LocalAddr, + RemoteAddr: s.connState.RemoteAddr, + TLS: tlsState, + }, func(identity string, data auth.ContextData) error { s.connState.AuthUser = identity s.connState.AuthPassword = data.Password return nil @@ -158,14 +169,14 @@ func (s *Session) cleanSession() { s.msgTask.End() } -func (s *Session) AuthPlain(username, password string) error { +func (s *Session) AuthPlain(ctx *module.AuthContext, username, password string) error { // Executed before authentication and session initialization. if err := s.endp.pipeline.RunEarlyChecks(context.TODO(), &s.connState); err != nil { return s.endp.wrapErr("", true, "AUTH", err) } // saslAuth will handle AuthMap and AuthNormalize. - err := s.endp.saslAuth.AuthPlain(username, password) + err := s.endp.saslAuth.AuthPlain(ctx, username, password) if err != nil { s.endp.log.Error("authentication failed", err, "username", username, "src_ip", s.connState.RemoteAddr) diff --git a/maddy.go b/maddy.go index 2a6e80635..8c705756a 100644 --- a/maddy.go +++ b/maddy.go @@ -51,6 +51,7 @@ import ( _ "github.com/foxcpp/maddy/internal/auth/pass_table" _ "github.com/foxcpp/maddy/internal/auth/plain_separate" _ "github.com/foxcpp/maddy/internal/auth/shadow" + _ "github.com/foxcpp/maddy/internal/auth/tls" _ "github.com/foxcpp/maddy/internal/check/authorize_sender" _ "github.com/foxcpp/maddy/internal/check/command" _ "github.com/foxcpp/maddy/internal/check/dkim" diff --git a/tests/sasl_external_test.go b/tests/sasl_external_test.go new file mode 100644 index 000000000..9e21a55f4 --- /dev/null +++ b/tests/sasl_external_test.go @@ -0,0 +1,212 @@ +//go:build integration + +/* +Maddy Mail Server - Composable all-in-one email server. +Copyright © 2019-2026 Max Mazurov , Maddy Mail Server contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package tests_test + +import ( + "crypto/tls" + "crypto/x509" + "os" + "strconv" + "testing" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "github.com/foxcpp/maddy/tests" + "github.com/stretchr/testify/require" +) + +func TestSMTPSASLExternalTLS(tt *testing.T) { + tt.Parallel() + t := tests.NewT(tt) + + smtpPort := t.Port("smtp") + t.DNS(nil) + t.Config(` + smtp tls://127.0.0.1:{env:TEST_PORT_smtp} { + hostname mx.maddy.test + tls { + loader file {env:TEST_PWD}/testdata/tls/server.crt {env:TEST_PWD}/testdata/tls/server.key + client_ca {env:TEST_PWD}/testdata/tls/ca.crt + client_auth verify_if_given + } + auth tls + auth dummy + + # Use authorize_sender to quickly check effective auth username via MAIL FROM. + defer_sender_reject + check { + authorize_sender + } + + deliver_to dummy + } + `) + t.Run(1) + defer t.Close() + + caPool := x509.NewCertPool() + caPEM, err := os.ReadFile("testdata/tls/ca.crt") + require.NoError(t, err) + caPool.AppendCertsFromPEM(caPEM) + + loadCert := func(t *tests.T, crtPath, keyPath string) tls.Certificate { + crt, err := tls.LoadX509KeyPair(crtPath, keyPath) + require.NoError(t, err) + return crt + } + + t.Subtest("no client cert", func(t *tests.T) { + // No client certificate provided - SASL EXTERNAL will fail. + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.Error(t, smtpConn.Auth(sasl.NewExternalClient(""))) + }) + + t.Subtest("wrong cert usage", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_san_email_no_usage.crt", + "testdata/tls/client_san_email_no_usage.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.Error(t, smtpConn.Auth(sasl.NewExternalClient(""))) + }) + + t.Subtest("CN cert", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_cn.crt", + "testdata/tls/client_cn.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.NoError(t, smtpConn.Auth(sasl.NewExternalClient(""))) + + require.NoError(t, smtpConn.Mail("cn@maddy.test", nil)) + }) + + t.Subtest("SAN email cert", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_san_email.crt", + "testdata/tls/client_san_email.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.NoError(t, smtpConn.Auth(sasl.NewExternalClient(""))) + + require.NoError(t, smtpConn.Mail("san@maddy.test", nil)) + }) + + t.Subtest("SAN email cert - multiple default", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_san_email_multi.crt", + "testdata/tls/client_san_email_multi.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.NoError(t, smtpConn.Auth(sasl.NewExternalClient(""))) + + require.NoError(t, smtpConn.Mail("san1@maddy.test", nil)) + }) + + t.Subtest("SAN email cert - multiple 1", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_san_email_multi.crt", + "testdata/tls/client_san_email_multi.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.NoError(t, smtpConn.Auth(sasl.NewExternalClient("san1@maddy.test"))) + + require.NoError(t, smtpConn.Mail("san1@maddy.test", nil)) + }) + + t.Subtest("SAN email cert - multiple 2", func(t *tests.T) { + smtpConn, err := smtp.DialTLS("127.0.0.1:"+strconv.Itoa(int(smtpPort)), &tls.Config{ + ServerName: "mx.maddy.test", + RootCAs: caPool, + Certificates: []tls.Certificate{ + loadCert( + t, + "testdata/tls/client_san_email_multi.crt", + "testdata/tls/client_san_email_multi.key", + ), + }, + }) + require.NoError(t, err) + defer smtpConn.Close() + + require.True(t, smtpConn.SupportsAuth(sasl.External)) + require.NoError(t, smtpConn.Auth(sasl.NewExternalClient("san2@maddy.test"))) + + require.NoError(t, smtpConn.Mail("san2@maddy.test", nil)) + }) +} diff --git a/tests/testdata/tls/init.sh b/tests/testdata/tls/init.sh new file mode 100644 index 000000000..1e53a8e0e --- /dev/null +++ b/tests/testdata/tls/init.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +# Generate CA key and certificate +openssl req -x509 -newkey rsa:4096 -keyout ca.key -out ca.crt -days 36500 -nodes -subj "/CN=maddy.test" + +# Generate server certificate. +openssl req -new -nodes -newkey rsa:4096 -keyout server.key -out server.req -batch \ + -subj "/CN=mx.maddy.test" \ + -addext "subjectAltName = DNS:mx.maddy.test" \ + -addext "keyUsage = keyEncipherment" \ + -addext "extendedKeyUsage = serverAuth" +openssl x509 -req -in server.req -CA ca.crt -CAkey ca.key -copy_extensions copy -out server.crt -days 36500 + +# Generate test client certs. +openssl req -new -nodes -newkey rsa:4096 -keyout client_cn.key -out client_cn.req -batch \ + -subj "/CN=cn@maddy.test" \ + -addext "keyUsage = digitalSignature" \ + -addext "extendedKeyUsage = clientAuth" +openssl x509 -req -in client_cn.req -CA ca.crt -CAkey ca.key -copy_extensions copy -out client_cn.crt -days 36500 + +openssl req -new -nodes -newkey rsa:4096 -keyout client_san_email.key -out client_san_email.req -batch \ + -subj "/CN=SAN test" \ + -addext "subjectAltName = email:san@maddy.test" \ + -addext "keyUsage = digitalSignature" \ + -addext "extendedKeyUsage = clientAuth" +openssl x509 -req -in client_san_email.req -CA ca.crt -CAkey ca.key -copy_extensions copy -out client_san_email.crt -days 36500 + +openssl req -new -nodes -newkey rsa:4096 -keyout client_san_email_multi.key -out client_san_email_multi.req -batch \ + -subj "/CN=SAN test" \ + -addext "subjectAltName = email:san1@maddy.test,email:san2@maddy.test" \ + -addext "keyUsage = digitalSignature" \ + -addext "extendedKeyUsage = clientAuth" +openssl x509 -req -in client_san_email_multi.req -CA ca.crt -CAkey ca.key -copy_extensions copy -out client_san_email_multi.crt -days 36500 + +openssl req -new -nodes -newkey rsa:4096 -keyout client_san_email_no_usage.key -out client_san_email_no_usage.req -batch \ + -subj "/CN=SAN test" \ + -addext "subjectAltName = email:san@maddy.test" +openssl x509 -req -in client_san_email_no_usage.req -CA ca.crt -CAkey ca.key -copy_extensions copy -out client_san_email_no_usage.crt -days 36500 + +rm *.req From 480bea929b29d2fd6e8a72c0d453fd9481e350d2 Mon Sep 17 00:00:00 2001 From: "fox.cpp" Date: 2026年5月16日 00:51:31 +0300 Subject: [PATCH 3/5] auth/oauth: Implement OAuth Bearer authentication --- .mkdocs.yml | 1 + Dockerfile | 2 +- docs/reference/auth/oauth.md | 172 +++++++ docs/reference/blob/fs.md | 22 +- docs/reference/blob/s3.md | 5 +- docs/tutorials/building-from-source.md | 8 +- framework/module/auth.go | 15 + go.mod | 8 +- go.sum | 16 +- internal/auth/oauth/oauth.go | 512 +++++++++++++++++++++ internal/auth/oauth/oauth_test.go | 392 ++++++++++++++++ internal/auth/sasl.go | 55 ++- internal/storage/blob/fs/fs.go | 50 +- internal/storage/blob/fs/fs_test.go | 12 +- internal/storage/blob/s3/s3.go | 1 + internal/table/cache.go | 43 ++ internal/table/{ => file}/file.go | 2 +- internal/table/{ => file}/file_test.go | 2 +- internal/table/{ => sql}/sql_query.go | 2 +- internal/table/{ => sql}/sql_query_test.go | 2 +- internal/table/{ => sql}/sql_table.go | 2 +- maddy.go | 3 + tests/sasl_oauth_test.go | 79 ++++ 23 files changed, 1361 insertions(+), 45 deletions(-) create mode 100644 docs/reference/auth/oauth.md create mode 100644 internal/auth/oauth/oauth.go create mode 100644 internal/auth/oauth/oauth_test.go create mode 100644 internal/table/cache.go rename internal/table/{ => file}/file.go (99%) rename internal/table/{ => file}/file_test.go (99%) rename internal/table/{ => sql}/sql_query.go (99%) rename internal/table/{ => sql}/sql_query_test.go (99%) rename internal/table/{ => sql}/sql_table.go (99%) create mode 100644 tests/sasl_oauth_test.go diff --git a/.mkdocs.yml b/.mkdocs.yml index ebe97935b..60f15352d 100644 --- a/.mkdocs.yml +++ b/.mkdocs.yml @@ -72,6 +72,7 @@ nav: - reference/auth/plain_separate.md - reference/auth/netauth.md - reference/auth/tls.md + - reference/auth/oauth.md - reference/config-syntax.md - Integration with software: - third-party/dovecot.md diff --git a/Dockerfile b/Dockerfile index 2da6211f4..dec3e4a72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS build-env +FROM golang:1.25-alpine AS build-env ARG ADDITIONAL_BUILD_TAGS="" diff --git a/docs/reference/auth/oauth.md b/docs/reference/auth/oauth.md new file mode 100644 index 000000000..4cc96f43f --- /dev/null +++ b/docs/reference/auth/oauth.md @@ -0,0 +1,172 @@ +# OAuth Bearer Token Authentication + +`auth.oauth` implements OAuth Bearer Token authentication as defined +in [RFC 7628][rfc7628] and [RFC 6750][rfc6750]. + +It is not compatible with non-standard XOAUTH2 implementations, such as those +used by Google and Microsoft. + +The provided token can be validated either by the server directly by decoding +JWT, or by making an introspection request ([RFC 7662][rfc7662]) to the +authorization server to validate the token and retrieve associated metadata. + +## Configuration directives + +``` +auth.oauth [] { + [debug yes | no] + [introspection auth | get | post | local] + [introspection_url ] + [http_header ] + [http_header ...] + [introspection_timeout 5s] + [scopes ] + [username_attribute ] + [active_attribute active] + [active_value true] + [jwt_key_id_template