318 lines
7.4 KiB
Go
318 lines
7.4 KiB
Go
// Package dovecot implements functions to interact with Dovecot's
|
|
// authentication service.
|
|
//
|
|
// In particular, it supports doing user authorization, and checking if a user
|
|
// exists. It is a very basic implementation, with only the minimum needed to
|
|
// cover chasquid's needs.
|
|
//
|
|
// https://wiki.dovecot.org/Design/AuthProtocol
|
|
// https://wiki.dovecot.org/Services#auth
|
|
package dovecot
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/textproto"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// DefaultTimeout to use. We expect Dovecot to be quite fast, but don't want
|
|
// to hang forever if something gets stuck.
|
|
const DefaultTimeout = 5 * time.Second
|
|
|
|
var (
|
|
errUsernameNotSafe = errors.New("username not safe (contains spaces)")
|
|
errFailedToConnect = errors.New("failed to connect to dovecot")
|
|
errNoUserdbSocket = errors.New("unable to find userdb socket")
|
|
errNoClientSocket = errors.New("unable to find client socket")
|
|
)
|
|
|
|
var defaultUserdbPaths = []*url.URL{
|
|
{Scheme: "unix", Path: "/var/run/dovecot/auth-chasquid-userdb"},
|
|
{Scheme: "unix", Path: "/var/run/dovecot/auth-userdb"},
|
|
}
|
|
|
|
var defaultClientPaths = []*url.URL{
|
|
{Scheme: "unix", Path: "/var/run/dovecot/auth-chasquid-client"},
|
|
{Scheme: "unix", Path: "/var/run/dovecot/auth-client"},
|
|
}
|
|
|
|
// Auth represents a particular Dovecot auth service to use.
|
|
type Auth struct {
|
|
addr struct {
|
|
mu *sync.Mutex
|
|
userdb string
|
|
client string
|
|
}
|
|
|
|
// Timeout for connection and I/O operations (applies on each call).
|
|
// Set to DefaultTimeout by NewAuth.
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// NewAuth returns a new connection against Dovecot authentication service. It
|
|
// takes the addresses of userdb and client sockets (usually paths as
|
|
// configured in dovecot).
|
|
func NewAuth(userdb, client string) *Auth {
|
|
a := &Auth{}
|
|
a.addr.mu = &sync.Mutex{}
|
|
a.addr.userdb = userdb
|
|
a.addr.client = client
|
|
a.Timeout = DefaultTimeout
|
|
return a
|
|
}
|
|
|
|
// String representation of this Auth, for human consumption.
|
|
func (a *Auth) String() string {
|
|
a.addr.mu.Lock()
|
|
defer a.addr.mu.Unlock()
|
|
return fmt.Sprintf("DovecotAuth(%q, %q)", a.addr.userdb, a.addr.client)
|
|
}
|
|
|
|
// Check to see if this auth is functional.
|
|
func (a *Auth) Check() error {
|
|
u, c, err := a.getAddrs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !(a.canDial(u) && a.canDial(c)) {
|
|
return errFailedToConnect
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Exists returns true if the user exists, false otherwise.
|
|
func (a *Auth) Exists(user string) (bool, error) {
|
|
var (
|
|
conn *textproto.Conn
|
|
err error
|
|
)
|
|
if !isUsernameSafe(user) {
|
|
return false, errUsernameNotSafe
|
|
}
|
|
|
|
userdb, _, err := a.getAddrs()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
conn, err = a.dial(userdb)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Dovecot greets us with version and server pid.
|
|
// VERSION\t<major>\t<minor>
|
|
// SPID\t<pid>
|
|
err = expect(conn, "VERSION\t1")
|
|
if err != nil {
|
|
return false, fmt.Errorf("error receiving version: %v", err)
|
|
}
|
|
err = expect(conn, "SPID\t")
|
|
if err != nil {
|
|
return false, fmt.Errorf("error receiving SPID: %v", err)
|
|
}
|
|
|
|
// Send our version, and then the request.
|
|
err = write(conn, "VERSION\t1\t1\n")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
err = write(conn, fmt.Sprintf("USER\t1\t%s\tservice=smtp\n", user))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Get the response, and we're done.
|
|
resp, err := conn.ReadLine()
|
|
if err != nil {
|
|
return false, fmt.Errorf("error receiving response: %v", err)
|
|
} else if strings.HasPrefix(resp, "USER\t1\t") {
|
|
return true, nil
|
|
} else if strings.HasPrefix(resp, "NOTFOUND\t") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("invalid response: %q", resp)
|
|
}
|
|
|
|
// Authenticate returns true if the password is valid for the user, false
|
|
// otherwise.
|
|
func (a *Auth) Authenticate(user, passwd string) (bool, error) {
|
|
var (
|
|
conn *textproto.Conn
|
|
err error
|
|
)
|
|
if !isUsernameSafe(user) {
|
|
return false, errUsernameNotSafe
|
|
}
|
|
|
|
_, client, err := a.getAddrs()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
conn, err = a.dial(client)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Send our version, and then our PID.
|
|
err = write(conn, fmt.Sprintf("VERSION\t1\t1\nCPID\t%d\n", os.Getpid()))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Read the server-side handshake. We don't care about the contents
|
|
// really, so just read all lines until we see the DONE.
|
|
for {
|
|
resp, err := conn.ReadLine()
|
|
if err != nil {
|
|
return false, fmt.Errorf("error receiving handshake: %v", err)
|
|
}
|
|
if resp == "DONE" {
|
|
break
|
|
}
|
|
}
|
|
|
|
// We only support PLAIN authentication, so construct the request.
|
|
// Note we set the "secured" option, with the assumpition that we got the
|
|
// password via a secure channel (like TLS). This is always true for
|
|
// chasquid by design, and simplifies the API.
|
|
// TODO: does dovecot handle utf8 domains well? do we need to encode them
|
|
// in IDNA first?
|
|
resp := base64.StdEncoding.EncodeToString(
|
|
[]byte(fmt.Sprintf("%s\x00%s\x00%s", user, user, passwd)))
|
|
err = write(conn, fmt.Sprintf(
|
|
"AUTH\t1\tPLAIN\tservice=smtp\tsecured\tno-penalty\tnologin\tresp=%s\n", resp))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Get the response, and we're done.
|
|
resp, err = conn.ReadLine()
|
|
if err != nil {
|
|
return false, fmt.Errorf("error receiving response: %v", err)
|
|
} else if strings.HasPrefix(resp, "OK\t1") {
|
|
return true, nil
|
|
} else if strings.HasPrefix(resp, "FAIL\t1") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("invalid response: %q", resp)
|
|
}
|
|
|
|
// Reload the authenticator. It's a no-op for dovecot, but it is needed to
|
|
// conform with the auth.Backend interface.
|
|
func (a *Auth) Reload() error {
|
|
return nil
|
|
}
|
|
|
|
func (a *Auth) dial(uri *url.URL) (*textproto.Conn, error) {
|
|
network := "tcp"
|
|
addr := uri.Host
|
|
switch uri.Scheme {
|
|
case "unix":
|
|
network = "unix"
|
|
addr = uri.Path
|
|
}
|
|
nc, err := net.DialTimeout(network, addr, a.Timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nc.SetDeadline(time.Now().Add(a.Timeout))
|
|
|
|
return textproto.NewConn(nc), nil
|
|
}
|
|
|
|
func expect(conn *textproto.Conn, prefix string) error {
|
|
resp, err := conn.ReadLine()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.HasPrefix(resp, prefix) {
|
|
return fmt.Errorf("got %q", resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func write(conn *textproto.Conn, msg string) error {
|
|
_, err := conn.W.Write([]byte(msg))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return conn.W.Flush()
|
|
}
|
|
|
|
// isUsernameSafe to use in the dovecot protocol?
|
|
// Unfortunately dovecot's protocol is not very robust wrt. whitespace,
|
|
// so we need to be careful.
|
|
func isUsernameSafe(user string) bool {
|
|
for _, r := range user {
|
|
if unicode.IsSpace(r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// getAddrs returns the addresses to the userdb and client sockets.
|
|
func (a *Auth) getAddrs() (*url.URL, *url.URL, error) {
|
|
a.addr.mu.Lock()
|
|
defer a.addr.mu.Unlock()
|
|
|
|
if a.addr.userdb == "" {
|
|
for _, u := range defaultUserdbPaths {
|
|
if a.canDial(u) {
|
|
a.addr.userdb = u.String()
|
|
break
|
|
}
|
|
}
|
|
if a.addr.userdb == "" {
|
|
return nil, nil, errNoUserdbSocket
|
|
}
|
|
}
|
|
|
|
if a.addr.client == "" {
|
|
for _, c := range defaultClientPaths {
|
|
if a.canDial(c) {
|
|
a.addr.client = c.String()
|
|
break
|
|
}
|
|
}
|
|
if a.addr.client == "" {
|
|
return nil, nil, errNoClientSocket
|
|
}
|
|
}
|
|
userdb, err := url.Parse(a.addr.userdb)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
client, err := url.Parse(a.addr.client)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return userdb, client, nil
|
|
}
|
|
|
|
func (a *Auth) canDial(uri *url.URL) bool {
|
|
fmt.Printf("Attempting %v\n", uri)
|
|
conn, err := a.dial(uri)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
conn.Close()
|
|
return true
|
|
}
|