Implement auth_map and storage_map at endpoint level

This makes auth_map do what its name implies. Old auth_map in storage
module is deprecated and will be removed in the next release.
This commit is contained in:
fox.cpp 2023-03-12 13:52:04 +03:00
parent 43c0325708
commit a7001ab730
No known key found for this signature in database
GPG key ID: 5B991F6215D2FCC0
18 changed files with 561 additions and 124 deletions

View file

@ -1,69 +1,157 @@
# Multiple domains configuration
## Separate account namespaces
By default, maddy uses email addresses as account identifiers for both
authentication and storage purposes. Therefore, account named `user@example.org`
is completely independent from `user@example.com`. They must be created
separately, may have different credentials and have separate IMAP mailboxes.
Given two domains, example.org and example.com. foo@example.org and
foo@example.com are different and completely independent accounts.
This makes it extremely easy to setup maddy to manage multiple otherwise
independent domains.
All changes needed to make it work is to make sure all domains are specified in
the `$(local_domains)` macro in the main configuration file. Note that you need
to pick one domain as a "primary" for use in auto-generated messages.
Default configuration file contains two macros - `$(primary_domain)` and
`$(local_domains)`. They are used to used in several places thorough the
file to configure message routing, security checks, etc.
In general, you should just add all domains you want maddy to manage to
`$(local_domains)`, like this:
```
$(primary_domain) = example.org
$(local_domains) = $(primary_domain) example.com
```
Note that you need to pick one domain as a "primary" for use in
auto-generated messages.
The base configuration is done. You can create accounts using
both domains in the name, send and receive messages and so on. Do not forget
to configure corresponding SPF, DMARC and MTA-STS records as was
recommended in the [introduction tutorial](tutorials/setting-up.md).
With that done, you can create accounts using both domains in the name, send
and receive messages and so on. Do not forget to configure corresponding SPF,
DMARC and MTA-STS records as was recommended in
the [introduction tutorial](tutorials/setting-up.md).
## Single account namespace
Also note that you do not really need a separate TLS certificate for each
managed domain. You can have one hostname e.g. mail.example.org set as an MX
record for mulitple domains.
You can configure maddy to only use local part of the email
as an account identifier instead of the complete email.
**If you want multiple domains to share username namespace**, you should change
several more options.
This needs two changes to default configuration:
You can make "user@example.org" and "user@example.com" users share the same
credentials of user "user" but have different IMAP mailboxes ("user@example.org"
and "user@example.com" correspondingly). For that, it is enough to set `auth_map`
globally to use `email_localpart` table:
```
storage.imapsql local_mailboxes {
...
delivery_map email_localpart
auth_normalize precis_casefold
auth_map email_localpart
```
This way, when user logs in as "user@example.org", "user" will be passed
to the authentication provider, but "user@example.org" will be passed to the
storage backend. You should create accounts like this:
```
maddy creds create user
maddy imap-acct create user@example.org
maddy imap-acct create user@example.com
```
**If you want accounts to also share the same IMAP storage of account named
"user"**, you can set `storage_map` in IMAP endpoint and `delivery_map` in
storage backend to use `email_locapart`:
```
straoge.imapsql local_mailboxes {
...
delivery_map email_localpart # deliver "user@*" to "user"
}
imap tls://0.0.0.0:993 {
...
storage &local_mailboxes
...
storage_map email_localpart # "user@*" accesses "user" mailbox
}
```
This way, when authenticating as `foxcpp`, it will be mapped to
`foxcpp` storage account. E.g. you will need to run
`maddy imap-accts create foxcpp`, without the domain part.
If you have existing accounts, you will need to rename them.
Change to `auth_normalize` is necessary so that normalization function
will not attempt to parse authentication identity as a email.
When a email is received, `delivery_map email_localpart` will strip
the domain part before looking up the account. That is,
`foxcpp@example.org` will be become just `foxcpp`.
You also might want to make it possible to log in without
specifying a domain at all. In this case, use `email_localpart_optional` for
both `auth_map` and `storage_map`.
You also need to make `authorize_sender` check (used in `submission` endpoint)
accept non-email usernames:
```
authorize_sender {
...
auth_normalize precis_casefold
user_to_email regexp "(.*)" "$1@$(primary_domain)"
user_to_email chain {
step email_localpart_optional # remove domain from username if present
step email_with_domains $(local_domains) # expand username with all allowed domains
}
}
```
Note that is would work only if clients use only one domain as sender (`$(primary_domain)`).
If you want to allow sending from all domains, you need to remove `authorize_sender` check
altogether since it is not currently supported.
After that you can create accounts without specifying the domain part:
```
maddy imap-acct create foxcpp
maddy creds create foxcpp
```
And authenticate using "foxcpp" in email clients.
## TL;DR
Messages for any foxcpp@* address with a domain in `$(local_domains)`
will be delivered to that mailbox.
Your options:
**"user@example.org" and "user@example.com" have distinct credentials and
distinct mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
```
Create accounts as:
```shell
maddy creds create user@example.org
maddy imap-acct create user@example.org
maddy creds create user@example.com
maddy imap-acct create user@example.com
```
**"user@example.org" and "user@example.com" have same credentials but
distinct mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
auth_map email_localpart
```
Create accounts as:
```shell
maddy creds create user
maddy imap-acct create user@example.org
maddy imap-acct create user@example.com
```
**"user@example.org", "user@example.com", "user" have same credentials and same
mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
auth_map email_localpart_optional # authenticating as "user@*" checks credentials for "user"
storage.imapsql local_mailboxes {
...
delivery_map email_localpart_optional # deliver "user@*" to "user" mailbox
}
imap tls://0.0.0.0:993 {
...
storage_map email_localpart_optional # authenticating as "user@*" accesses "user" mailboxes
}
submission tls://0.0.0.0:465 {
check {
authorize_sender {
...
user_to_email chain {
step email_localpart_optional # remove domain from username if present
step email_with_domains $(local_domains) # expand username with all allowed domains
}
}
}
...
}
```
Create accounts as:
```shell
maddy creds create user
maddy imap-acct create user
```

View file

@ -16,8 +16,8 @@ check.authorize_sender {
malformed_action reject
err_action reject
auth_normalize precis_casefold_email
from_normalize precis_casefold_email
auth_normalize auto
from_normalize auto
}
```
```
@ -88,25 +88,26 @@ What to do if From or Sender header fields contain malformed values.
What to do if error happens during prepare\_email or user\_to\_email lookup.
**Syntax:** auth\_normalize _action_ <br>
**Default:** precis\_casefold\_email
**Default:** auto
Normalization function to apply to authorization username before
further processing.
Available options:
- precis\_casefold\_email PRECIS UsernameCaseMapped profile + Unicode form for domain
- precis\_casefold PRECIS UsernameCaseMapped profile for the entire string
- precis\_email PRECIS UsernameCasePreserved profile + Unicode form for domain
- precis PRECIS UsernameCasePreserved profile for the entire string
- casefold Convert to lower case
- noop Nothing
- `auto` `precis_casefold_email` for valid emails, `precise_casefold` otherwise.
- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain
- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string
- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain
- `precis` PRECIS UsernameCasePreserved profile for the entire string
- `casefold` Convert to lower case
- `noop` Nothing
PRECIS profiles are defined by RFC 8265. In short, they make sure
that Unicode strings that look the same will be compared as if they were
the same. CaseMapped profiles also convert strings to lower case.
**Syntax:** from\_normalize _action_ <br>
**Default:** precis\_casefold\_email
**Default:** auto
Normalization function to apply to email addresses before
further processing.

View file

@ -20,6 +20,10 @@ imap tcp://0.0.0.0:143 tls://0.0.0.0:993 {
insecure_auth no
auth pam
storage &local_mailboxes
auth_map identity
auth_map_normalize auto
storage_map identity
storage_map_normalize auto
}
```
@ -62,4 +66,46 @@ Use the specified module for authentication.
**Syntax**: storage _module\_reference\_
Use the specified module for message storage.
**Required.**
**Required.**
**Syntax**: storage\_map _module\_reference_ <br>
**Default**: identity
Use the specified table to map SASL usernames to storage account names.
Before username is looked up, it is normalized using function defined by
`storage_map_normalize`.
This directive is useful if you want users user@example.org and user@example.com
to share the same storage account named "user". In this case, use
```
storage_map email_localpart
```
Note that `storage_map` does not affect the username passed to the
authentication provider.
It also does not affect how message delivery is handled, you should specify
`delivery_map` in storage module to define how to map email addresses
to storage accounts. E.g.
```
storage.imapsql local_mailboxes {
...
delivery_map email_localpart # deliver "user@*" to mailbox for "user"
}
```
**Syntax**: storage\_map_normalize _function_ <br>
**Default**: auto
Same as `auth_map_normalize` but for `storage_map`.
**Syntax**: auth\_map_normalize _function_ <br>
**Default**: auto
Overrides global `auth_map_normalize` value for this endpoint.
See [Global configuration](/reference/global-config) for details.

View file

@ -1,6 +1,6 @@
# Global configuration directives
These directives can be specified outside of any
These directives can be specified outside of any
configuration blocks and they are applied to all modules.
Some directives can be overridden on per-module basis (e.g. hostname).
@ -23,6 +23,56 @@ objects. Should be writable.
Internet hostname of this mail server. Typicall FQDN is used. It is recommended
to make sure domain specified here resolved to the public IP of the server.
**Syntax**: auth\_map _module\_reference_ <br>
**Default**: identity
Use the specified table to translate SASL usernames before passing it to the
authentication provider.
Before username is looked up, it is normalized using function defined by
`auth_map_normalize`.
Note that `auth_map` does not affect the storage account name used. You probably
should also use `storage_map` in IMAP config block to handle this.
This directive is useful if used authentication provider does not support
using emails as usernames but you still want users to have separate mailboxes
on separate domains. In this case, use it with `email_localpart` table:
```
auth_map email_localpart
```
With this configuration, `user@example.org` and `user@example.com` will use
`user` credentials when authenticating, but will access `user@example.org` and
`user@example.com` mailboxes correspondingly. If you want to also accept
`user` as a username, use `auth_map email_localpart_optional`.
If you want `user@example.org` and `user@example.com` to have the same mailbox,
also set `storage_map` in IMAP config block to use `email_localpart`
(or `email_localpart_optional` if you want to also accept just "user"):
```
storage_map email_localpart
```
In this case you will need to create storage accounts without domain part in
the name:
```
maddy imap-acct create user # instead of user@example.org
```
**Syntax**: auth\_map_normalize _function_ <br>
**Default**: auto
Normalization function to apply to SASL usernames before mapping
them to storage accounts.
Available options:
- `auto` `precis_casefold_email` for valid emails, `precise_casefold` otherwise.
- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain
- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string
- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain
- `precis` PRECIS UsernameCasePreserved profile for the entire string
- `casefold` Convert to lower case
- `noop` Nothing
**Syntax**: autogenerated\_msg\_domain _domain_ <br>
**Default**: not specified

View file

@ -156,6 +156,8 @@ See auth\_normalize.
**Syntax**: auth\_map **table** <br>
**Default**: identity
**DEPRECATED:** Use `storage_map` in imap config instead.
Use specified table module to map authentication
usernames to mailbox names.
@ -165,6 +167,8 @@ auth\_map.
**Syntax**: auth\_normalize _name_ <br>
**Default**: precis\_casefold\_email
**DEPRECATED:** Use `storage_map_normalize` in imap config instead.
Normalization function to apply to authentication usernames before mapping
them to mailboxes.

View file

@ -12,8 +12,8 @@ with `table.chain`. Example:
```
modify {
replace_rcpt chain {
email_local_part
email_with_domains example.org example.com
step email_local_part
step email_with_domains example.org example.com
}
}
```

View file

@ -65,42 +65,12 @@ auth.pam local_authdb {
## Account names
Since PAM does not use emails for authentication you should also
configure storage backend to use username only as an account identifier,
not full email addresses:
```
storage.imapsql local_mailboxes {
...
delivery_map email_localpart
auth_normalize precis_casefold
}
```
Since PAM does not use emails for authentication you should configure
maddy to either strip domain part when checking credentials or do not
use email when authenticating.
This way, when authenticating as `foxcpp`, it will be mapped to
`foxcpp` storage account. E.g. you will need to run
`maddy imap-accts create foxcpp`, without the domain part.
If you have existing accounts, you will need to rename them.
Change to `auth_normalize` is necessary so that normalization function
will not attempt to parse authentication identity as a email.
When a email is received, `delivery_map email_localpart` will strip
the domain part before looking up the account. That is,
`foxcpp@example.org` will be become just `foxcpp`.
You also need to make `authorize_sender` check (used in `submission` endpoint)
accept non-email usernames:
```
authorize_sender {
...
auth_normalize precis_casefold
user_to_email regexp "(.*)" "$1@$(primary_domain)"
}
```
Note that is would work only if clients use only one domain as sender (`$(primary_domain)`).
If you want to allow sending from all domains, you need to remove `authorize_sender` check
altogether since it is not currently supported.
See [Multiple domains configuration](/multiple-domains) for how to configure
authentication.
## PAM service

View file

@ -31,13 +31,14 @@ func MessageCheck(globals map[string]interface{}, args []string, block config.No
return check, nil
}
// deliveryDirective is a callback for use in config.Map.Custom.
// DeliveryDirective is a callback for use in config.Map.Custom.
//
// It does all work necessary to create a module instance from the config
// directive with the following structure:
// directive_name mod_name [inst_name] [{
// inline_mod_config
// }]
//
// directive_name mod_name [inst_name] [{
// inline_mod_config
// }]
//
// Note that if used configuration structure lacks directive_name before mod_name - this function
// should not be used (call DeliveryTarget directly).
@ -77,6 +78,17 @@ func StorageDirective(m *config.Map, node config.Node) (interface{}, error) {
return backend, nil
}
// Table is a convenience wrapper for TableDirective.
//
// cfg.Bool(...)
// modconfig.Table(cfg, "auth_map", false, false, nil, &mod.authMap)
// cfg.Process()
func Table(cfg *config.Map, name string, inheritGlobal, required bool, defaultVal module.Table, store *module.Table) {
cfg.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, TableDirective, store)
}
func TableDirective(m *config.Map, node config.Node) (interface{}, error) {
var tbl module.Table
if err := ModuleFromNode("table", node.Args, node, m.Globals, &tbl); err != nil {

View file

@ -19,6 +19,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
package auth
import (
"context"
"errors"
"fmt"
"net"
@ -28,6 +29,7 @@ import (
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/authz"
)
var (
@ -38,12 +40,18 @@ var (
// SASLAuth is a wrapper that initializes sasl.Server using authenticators that
// call maddy module objects.
//
// It also handles username translation using auth_map and auth_map_normalize
// (AuthMap and AuthMapNormalize should be set).
//
// It supports reporting of multiple authorization identities so multiple
// accounts can be associated with a single set of credentials.
type SASLAuth struct {
Log log.Logger
OnlyFirstID bool
AuthMap module.Table
AuthNormalize authz.NormalizeFunc
Plain []module.PlainAuth
}
@ -57,6 +65,31 @@ func (s *SASLAuth) SASLMechanisms() []string {
return mechs
}
func (s *SASLAuth) usernameForAuth(ctx context.Context, saslUsername string) (string, error) {
saslUsername, err := s.AuthNormalize(saslUsername)
if err != nil {
return "", err
}
if s.AuthMap == nil {
return saslUsername, nil
}
mapped, ok, err := s.AuthMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", ErrInvalidAuthCred
}
if saslUsername != mapped {
s.Log.DebugMsg("using mapped username for authentication", "username", saslUsername, "mapped_username", mapped)
}
return mapped, nil
}
func (s *SASLAuth) AuthPlain(username, password string) error {
if len(s.Plain) == 0 {
return ErrUnsupportedMech
@ -64,6 +97,11 @@ func (s *SASLAuth) AuthPlain(username, password string) error {
var lastErr error
for _, p := range s.Plain {
username, err := s.usernameForAuth(context.TODO(), username)
if err != nil {
return err
}
lastErr = p.AuthPlain(username, password)
if lastErr == nil {
return nil

View file

@ -7,9 +7,25 @@ import (
"golang.org/x/text/secure/precis"
)
type NormalizeFunc func(string) (string, error)
func NormalizeNoop(s string) (string, error) {
return s, nil
}
// NormalizeAuto applies address.PRECISFold to valid emails and
// plain UsernameCaseMapped profile to other strings.
func NormalizeAuto(s string) (string, error) {
if address.Valid(s) {
return address.PRECISFold(s)
}
return precis.UsernameCaseMapped.CompareKey(s)
}
// NormalizeFuncs defines configurable normalization functions to be used
// in authentication and authorization routines.
var NormalizeFuncs = map[string]func(string) (string, error){
var NormalizeFuncs = map[string]NormalizeFunc{
"auto": NormalizeAuto,
"precis_casefold_email": address.PRECISFold,
"precis_casefold": precis.UsernameCaseMapped.CompareKey,
"precis_email": address.PRECIS,
@ -17,7 +33,5 @@ var NormalizeFuncs = map[string]func(string) (string, error){
"casefold": func(s string) (string, error) {
return strings.ToLower(s), nil
},
"noop": func(s string) (string, error) {
return s, nil
},
"noop": NormalizeNoop,
}

View file

@ -20,7 +20,6 @@ package authorize_sender
import (
"context"
"fmt"
"net/mail"
"github.com/emersion/go-message/textproto"
@ -49,8 +48,8 @@ type Check struct {
noMatchAction modconfig.FailAction
errAction modconfig.FailAction
fromNorm func(string) (string, error)
authNorm func(string) (string, error)
fromNorm authz.NormalizeFunc
authNorm authz.NormalizeFunc
}
func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
@ -89,29 +88,15 @@ func (c *Check) Init(cfg *config.Map) error {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.errAction)
var (
authNormalize string
fromNormalize string
ok bool
)
cfg.String("auth_normalize", false, false,
"precis_casefold_email", &authNormalize)
cfg.String("from_normalize", false, false,
"precis_casefold_email", &fromNormalize)
config.EnumMapped(cfg, "auth_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&c.authNorm)
config.EnumMapped(cfg, "from_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&c.fromNorm)
if _, err := cfg.Process(); err != nil {
return err
}
c.authNorm, ok = authz.NormalizeFuncs[authNormalize]
if !ok {
return fmt.Errorf("%v: unknown normalization function: %v", modName, authNormalize)
}
c.fromNorm, ok = authz.NormalizeFuncs[fromNormalize]
if !ok {
return fmt.Errorf("%v: unknown normalization function: %v", modName, fromNormalize)
}
return nil
}

View file

@ -28,9 +28,11 @@ import (
"github.com/emersion/go-sasl"
dovecotsasl "github.com/foxcpp/go-dovecot-sasl"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
)
const modName = "dovecot_sasld"
@ -42,6 +44,9 @@ type Endpoint struct {
listenersWg sync.WaitGroup
authNormalize authz.NormalizeFunc
authMap module.Table
srv *dovecotsasl.Server
}
@ -67,6 +72,9 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
cfg.Callback("auth", func(m *config.Map, node config.Node) error {
return endp.saslAuth.AddProvider(m, node)
})
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.authNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.authMap)
if _, err := cfg.Process(); err != nil {
return err
}
@ -74,6 +82,8 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
endp.srv = dovecotsasl.NewServer()
endp.srv.Log = stdlog.New(endp.log, "", 0)
endp.saslAuth.AuthMap = endp.authMap
endp.saslAuth.AuthNormalize = endp.authNormalize
for _, mech := range endp.saslAuth.SASLMechanisms() {
mech := mech
endp.srv.AddMechanism(mech, mechInfo[mech], func(req *dovecotsasl.AuthReq) sasl.Server {

View file

@ -19,6 +19,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
package imap
import (
"context"
"crypto/tls"
"errors"
"fmt"
@ -42,6 +43,7 @@ import (
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
"github.com/foxcpp/maddy/internal/updatepipe"
)
@ -56,6 +58,11 @@ type Endpoint struct {
saslAuth auth.SASLAuth
storageNormalize authz.NormalizeFunc
storageMap module.Table
authNormalize authz.NormalizeFunc
authMap module.Table
Log log.Logger
}
@ -87,6 +94,12 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
cfg.Bool("io_debug", false, false, &ioDebug)
cfg.Bool("io_errors", false, false, &ioErrors)
cfg.Bool("debug", true, false, &endp.Log.Debug)
config.EnumMapped(cfg, "storage_map_normalize", false, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.storageNormalize)
modconfig.Table(cfg, "storage_map", false, false, nil, &endp.storageMap)
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.authNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.authMap)
if _, err := cfg.Process(); err != nil {
return err
}
@ -123,6 +136,8 @@ func (endp *Endpoint) Init(cfg *config.Map) error {
return err
}
endp.saslAuth.AuthNormalize = endp.authNormalize
endp.saslAuth.AuthMap = endp.authMap
for _, mech := range endp.saslAuth.SASLMechanisms() {
mech := mech
endp.serv.EnableAuth(mech, func(c imapserver.Conn) sasl.Server {
@ -194,8 +209,63 @@ func (endp *Endpoint) Close() error {
return nil
}
func (endp *Endpoint) usernameForAuth(ctx context.Context, saslUsername string) (string, error) {
saslUsername, err := endp.authNormalize(saslUsername)
if err != nil {
return "", err
}
if endp.authMap == nil {
return saslUsername, nil
}
mapped, ok, err := endp.authMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", imapbackend.ErrInvalidCredentials
}
return mapped, nil
}
func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername string) (string, error) {
saslUsername, err := endp.storageNormalize(saslUsername)
if err != nil {
return "", err
}
if endp.storageMap == nil {
return saslUsername, nil
}
mapped, ok, err := endp.storageMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", imapbackend.ErrInvalidCredentials
}
if saslUsername != mapped {
endp.Log.DebugMsg("using mapped username for storage", "username", saslUsername, "mapped_username", mapped)
}
return mapped, nil
}
func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error {
u, err := endp.Store.GetOrCreateIMAPAcct(identity)
username, err := endp.usernameForStorage(context.TODO(), identity)
if err != nil {
if errors.Is(err, imapbackend.ErrInvalidCredentials) {
return err
}
endp.Log.Error("failed to determine storage account name", err, "username", username)
return fmt.Errorf("internal server error")
}
u, err := endp.Store.GetOrCreateIMAPAcct(username)
if err != nil {
return err
}
@ -206,13 +276,23 @@ 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)
if err != nil {
endp.Log.Error("authentication failed", err, "username", username, "src_ip", connInfo.RemoteAddr)
return nil, imapbackend.ErrInvalidCredentials
}
return endp.Store.GetOrCreateIMAPAcct(username)
storageUsername, err := endp.usernameForStorage(context.TODO(), username)
if err != nil {
if errors.Is(err, imapbackend.ErrInvalidCredentials) {
return nil, err
}
endp.Log.Error("authentication failed due to an internal error", err, "username", username, "src_ip", connInfo.RemoteAddr)
return nil, fmt.Errorf("internal server error")
}
return endp.Store.GetOrCreateIMAPAcct(storageUsername)
}
func (endp *Endpoint) I18NLevel() int {

View file

@ -154,6 +154,7 @@ func (s *Session) AuthPlain(username, password string) error {
return s.endp.wrapErr("", true, "AUTH", err)
}
// saslAuth will handle AuthMap and AuthNormalize.
err := s.endp.saslAuth.AuthPlain(username, password)
if err != nil {
s.endp.Log.Error("authentication failed", err, "username", username, "src_ip", s.connState.RemoteAddr)

View file

@ -43,6 +43,7 @@ import (
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
"github.com/foxcpp/maddy/internal/limits"
"github.com/foxcpp/maddy/internal/msgpipeline"
"golang.org/x/net/idna"
@ -68,6 +69,9 @@ type Endpoint struct {
maxReceived int
maxHeaderBytes int
authNormalize authz.NormalizeFunc
authMap module.Table
listenersWg sync.WaitGroup
Log log.Logger
@ -242,6 +246,9 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error {
return endp.saslAuth.AddProvider(m, node)
})
cfg.String("hostname", true, true, "", &hostname)
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.authNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.authMap)
cfg.Duration("write_timeout", false, false, 1*time.Minute, &endp.serv.WriteTimeout)
cfg.Duration("read_timeout", false, false, 10*time.Minute, &endp.serv.ReadTimeout)
cfg.DataSize("max_message_size", false, false, 32*1024*1024, &endp.serv.MaxMessageBytes)
@ -299,6 +306,8 @@ func (endp *Endpoint) setConfig(cfg *config.Map) error {
return fmt.Errorf("%s: auth. provider must be set for submission endpoint", endp.name)
}
}
endp.saslAuth.AuthNormalize = endp.authNormalize
endp.saslAuth.AuthMap = endp.authMap
for _, mech := range endp.saslAuth.SASLMechanisms() {
// The code below lacks handling to set AuthPassword. Don't
// override sasl.Plain handler so Login() will be called as usual.
@ -356,6 +365,31 @@ func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
return nil
}
func (endp *Endpoint) usernameForAuth(ctx context.Context, saslUsername string) (string, error) {
saslUsername, err := endp.authNormalize(saslUsername)
if err != nil {
return "", err
}
if endp.authMap == nil {
return saslUsername, nil
}
mapped, ok, err := endp.authMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", &smtp.SMTPError{
Code: 535,
EnhancedCode: smtp.EnhancedCode{5, 7, 8},
Message: "Invalid credentials",
}
}
return mapped, nil
}
func (endp *Endpoint) NewSession(conn *smtp.Conn) (smtp.Session, error) {
sess := endp.newSession(conn)

View file

@ -151,7 +151,7 @@ func (store *Storage) Init(cfg *config.Map) error {
cfg.Custom("auth_map", false, false, func() (interface{}, error) {
return nil, nil
}, modconfig.TableDirective, &store.authMap)
cfg.String("auth_normalize", false, false, "precis_casefold_email", &authNormalize)
cfg.String("auth_normalize", false, false, "auto", &authNormalize)
cfg.Custom("delivery_map", false, false, func() (interface{}, error) {
return nil, nil
}, modconfig.TableDirective, &store.deliveryMap)
@ -189,6 +189,9 @@ func (store *Storage) Init(cfg *config.Map) error {
}
}
if authNormalize != "auto" {
store.Log.Msg("auth_normalize in storage.imapsql is deprecated and will be removed in the next release, use storage_map in imap config instead")
}
authNormFunc, ok := authz.NormalizeFuncs[authNormalize]
if !ok {
return errors.New("imapsql: unknown normalization function: " + authNormalize)
@ -197,6 +200,7 @@ func (store *Storage) Init(cfg *config.Map) error {
return authNormFunc(s)
}
if store.authMap != nil {
store.Log.Msg("auth_map in storage.imapsql is deprecated and will be removed in the next release, use storage_map in imap config instead")
store.authNormalize = func(ctx context.Context, username string) (string, error) {
username, err := authNormFunc(username)
if err != nil {

View file

@ -31,10 +31,12 @@ import (
"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/tls"
"github.com/foxcpp/maddy/framework/hooks"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/authz"
maddycli "github.com/foxcpp/maddy/internal/cli"
"github.com/urfave/cli/v2"
@ -297,6 +299,8 @@ func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, erro
globals.StringList("auth_domains", false, false, nil, nil)
globals.Custom("log", false, false, defaultLogOutput, logOutput, &log.DefaultLogger.Out)
globals.Bool("debug", false, log.DefaultLogger.Debug, &log.DefaultLogger.Debug)
config.EnumMapped(globals, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto, nil)
modconfig.Table(globals, "auth_map", true, false, nil, nil)
globals.AllowUnknown()
unknown, err := globals.Process()
return globals.Values, unknown, err

96
tests/imap_test.go Normal file
View file

@ -0,0 +1,96 @@
//go:build integration && cgo && !nosqlite3
// +build integration,cgo,!nosqlite3
package tests_test
import (
"testing"
"github.com/foxcpp/maddy/tests"
)
func TestIMAPEndpointAuthMap(tt *testing.T) {
tt.Parallel()
t := tests.NewT(tt)
t.DNS(nil)
t.Port("imap")
t.Config(`
storage.imapsql test_store {
driver sqlite3
dsn imapsql.db
}
imap tcp://127.0.0.1:{env:TEST_PORT_imap} {
tls off
auth_map email_localpart
auth pass_table static {
entry "user" "bcrypt:$2a$10$E.AuCH3oYbaRrETXfXwc0.4jRAQBbanpZiCfudsJz9bHzLr/qj6ti" # password: 123
}
storage &test_store
}
`)
t.Run(1)
defer t.Close()
imapConn := t.Conn("imap")
defer imapConn.Close()
imapConn.ExpectPattern(`\* OK *`)
imapConn.Writeln(". LOGIN user@example.org 123")
imapConn.ExpectPattern(". OK *")
imapConn.Writeln(". SELECT INBOX")
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`\* *`)
imapConn.ExpectPattern(`. OK *`)
}
func TestIMAPEndpointStorageMap(tt *testing.T) {
tt.Parallel()
t := tests.NewT(tt)
t.DNS(nil)
t.Port("imap")
t.Config(`
storage.imapsql test_store {
driver sqlite3
dsn imapsql.db
}
imap tcp://127.0.0.1:{env:TEST_PORT_imap} {
tls off
storage_map email_localpart
auth_map email_localpart
auth pass_table static {
entry "user" "bcrypt:$2a$10$z9SvUwUjkY8wKOWd9IbISeEmbJua2cXRPqw7s2BnLXJuc6pIMPncK" # password: 123
}
storage &test_store
}
`)
t.Run(1)
defer t.Close()
imapConn := t.Conn("imap")
defer imapConn.Close()
imapConn.ExpectPattern(`\* OK *`)
imapConn.Writeln(". LOGIN user@example.org 123")
imapConn.ExpectPattern(". OK *")
imapConn.Writeln(". CREATE testbox")
imapConn.ExpectPattern(". OK *")
imapConn2 := t.Conn("imap")
defer imapConn2.Close()
imapConn2.ExpectPattern(`\* OK *`)
imapConn2.Writeln(". LOGIN user@example.com 123")
imapConn2.ExpectPattern(". OK *")
imapConn2.Writeln(`. LIST "" "*"`)
imapConn2.Expect(`* LIST (\HasNoChildren) "." INBOX`)
imapConn2.Expect(`* LIST (\HasNoChildren) "." "testbox"`)
imapConn2.ExpectPattern(". OK *")
}