git » chasquid » commit 51e7c5c

dovecot: Dovecot authentication package

author Alberto Bertogli
2017-12-09 21:35:27 UTC
committer Alberto Bertogli
2018-02-10 23:01:23 UTC
parent d4992ef8c579fccda015662976578e587ca6cca4

dovecot: Dovecot authentication package

This patch adds a new package which implements two basic primitives for
authenticating against dovecot ("user exists", and "check password").

It is still experimental/work in progress.

Makefile +7 -2
cmd/dovecot-auth-cli/.gitignore +2 -0
cmd/dovecot-auth-cli/dovecot-auth-cli.go +36 -0
cmd/dovecot-auth-cli/test.sh +21 -0
cmd/dovecot-auth-cli/test_auth_error.cmy +21 -0
cmd/dovecot-auth-cli/test_auth_no.cmy +21 -0
cmd/dovecot-auth-cli/test_auth_yes.cmy +21 -0
cmd/dovecot-auth-cli/test_exists_notfound.cmy +16 -0
cmd/dovecot-auth-cli/test_exists_yes.cmy +15 -0
cmd/dovecot-auth-cli/test_missing_socket.cmy +8 -0
internal/auth/auth_test.go +2 -0
internal/dovecot/dovecot.go +276 -0
internal/dovecot/dovecot_test.go +111 -0

diff --git a/Makefile b/Makefile
index b1fd20e..17463aa 100644
--- a/Makefile
+++ b/Makefile
@@ -11,7 +11,7 @@ endif
 
 default: chasquid
 
-all: chasquid chasquid-util smtp-check spf-check mda-lmtp
+all: chasquid chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli
 
 
 chasquid:
@@ -33,11 +33,15 @@ spf-check:
 mda-lmtp:
 	go build ${GOFLAGS} ./cmd/mda-lmtp/
 
+dovecot-auth-cli:
+	go build ${GOFLAGS} ./cmd/dovecot-auth-cli/
+
 test:
 	go test ${GOFLAGS} ./...
 	setsid -w ./test/run.sh
 	setsid -w ./cmd/chasquid-util/test.sh
 	setsid -w ./cmd/mda-lmtp/test.sh
+	setsid -w ./cmd/dovecot-auth-cli/test.sh
 
 
 install-binaries: chasquid chasquid-util smtp-check mda-lmtp
@@ -54,4 +58,5 @@ install-config-skeleton:
 	fi
 
 
-.PHONY: chasquid chasquid-util smtp-check spf-check mda-lmtp test
+.PHONY: chasquid test \
+	chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli
diff --git a/cmd/dovecot-auth-cli/.gitignore b/cmd/dovecot-auth-cli/.gitignore
new file mode 100644
index 0000000..ec06b5f
--- /dev/null
+++ b/cmd/dovecot-auth-cli/.gitignore
@@ -0,0 +1,2 @@
+*.log
+dovecot-auth-cli
diff --git a/cmd/dovecot-auth-cli/dovecot-auth-cli.go b/cmd/dovecot-auth-cli/dovecot-auth-cli.go
new file mode 100644
index 0000000..aee3a92
--- /dev/null
+++ b/cmd/dovecot-auth-cli/dovecot-auth-cli.go
@@ -0,0 +1,36 @@
+// CLI used for testing the dovecot authentication package.
+//
+// NOT for production use.
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"blitiri.com.ar/go/chasquid/internal/dovecot"
+)
+
+func main() {
+	a := dovecot.NewAuth(os.Args[1]+"-userdb", os.Args[1]+"-client")
+
+	var ok bool
+	var err error
+
+	switch os.Args[2] {
+	case "exists":
+		ok, err = a.Exists(os.Args[3])
+	case "auth":
+		ok, err = a.Authenticate(os.Args[3], os.Args[4])
+	default:
+		fmt.Printf("unknown subcommand\n")
+		os.Exit(1)
+	}
+
+	if ok {
+		fmt.Printf("yes\n")
+		return
+	}
+
+	fmt.Printf("no: %v\n", err)
+	os.Exit(1)
+}
diff --git a/cmd/dovecot-auth-cli/test.sh b/cmd/dovecot-auth-cli/test.sh
new file mode 100755
index 0000000..7a9a917
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -e
+. $(dirname ${0})/../../test/util/lib.sh
+
+init
+
+# Build the binary once, so we can use it and launch it in chamuyero scripts.
+# Otherwise, we not only spend time rebuilding it over and over, but also "go
+# run" masks the exit code, which is something we care about.
+go build dovecot-auth-cli.go
+
+for i in *.cmy; do
+	if ! chamuyero $i > $i.log 2>&1 ; then
+		echo "# Test $i failed, log follows"
+		cat $i.log
+		exit 1
+	fi
+done
+
+success
diff --git a/cmd/dovecot-auth-cli/test_auth_error.cmy b/cmd/dovecot-auth-cli/test_auth_error.cmy
new file mode 100644
index 0000000..75bc37c
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_auth_error.cmy
@@ -0,0 +1,21 @@
+
+client unix_listen .dovecot-client
+
+c = ./dovecot-auth-cli .dovecot auth username password
+
+client -> VERSION	1	1
+client -> SPID	12345
+client -> CUID	12345
+client -> COOKIE	lovelycookie
+client -> MECH	PLAIN
+client -> MECH	LOGIN
+client -> DONE
+
+client <- VERSION	1	1
+client <~ CPID	
+
+client <- AUTH	1	PLAIN	service=smtp	secured	no-penalty	nologin	resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
+client -> OTHER
+
+c <~ no: invalid response
+c wait 1
diff --git a/cmd/dovecot-auth-cli/test_auth_no.cmy b/cmd/dovecot-auth-cli/test_auth_no.cmy
new file mode 100644
index 0000000..365e459
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_auth_no.cmy
@@ -0,0 +1,21 @@
+
+client unix_listen .dovecot-client
+
+c = ./dovecot-auth-cli .dovecot auth username password
+
+client -> VERSION	1	1
+client -> SPID	12345
+client -> CUID	12345
+client -> COOKIE	lovelycookie
+client -> MECH	PLAIN
+client -> MECH	LOGIN
+client -> DONE
+
+client <- VERSION	1	1
+client <~ CPID	
+
+client <- AUTH	1	PLAIN	service=smtp	secured	no-penalty	nologin	resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
+client -> FAIL	1
+
+c <- no: <nil>
+c wait 1
diff --git a/cmd/dovecot-auth-cli/test_auth_yes.cmy b/cmd/dovecot-auth-cli/test_auth_yes.cmy
new file mode 100644
index 0000000..63dbf77
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_auth_yes.cmy
@@ -0,0 +1,21 @@
+
+client unix_listen .dovecot-client
+
+c = ./dovecot-auth-cli .dovecot auth username password
+
+client -> VERSION	1	1
+client -> SPID	12345
+client -> CUID	12345
+client -> COOKIE	lovelycookie
+client -> MECH	PLAIN
+client -> MECH	LOGIN
+client -> DONE
+
+client <- VERSION	1	1
+client <~ CPID	
+
+client <- AUTH	1	PLAIN	service=smtp	secured	no-penalty	nologin	resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
+client -> OK	1
+
+c <- yes
+c wait 0
diff --git a/cmd/dovecot-auth-cli/test_exists_notfound.cmy b/cmd/dovecot-auth-cli/test_exists_notfound.cmy
new file mode 100644
index 0000000..20309e6
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_exists_notfound.cmy
@@ -0,0 +1,16 @@
+
+userdb unix_listen .dovecot-userdb
+
+c = ./dovecot-auth-cli .dovecot exists username
+
+userdb -> VERSION	1	1
+userdb -> SPID	12345
+
+userdb <- VERSION	1	1
+userdb <- USER	1	username	service=smtp
+
+userdb -> NOTFOUND	1
+
+c wait 1
+
+c <- no: <nil>
diff --git a/cmd/dovecot-auth-cli/test_exists_yes.cmy b/cmd/dovecot-auth-cli/test_exists_yes.cmy
new file mode 100644
index 0000000..6590928
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_exists_yes.cmy
@@ -0,0 +1,15 @@
+
+userdb unix_listen .dovecot-userdb
+
+c = ./dovecot-auth-cli .dovecot exists username
+
+userdb -> VERSION	1	1
+userdb -> SPID	12345
+
+userdb <- VERSION	1	1
+userdb <- USER	1	username	service=smtp
+
+userdb -> USER	1	username	system_groups_user=blah uid=10  gid=10
+
+c <- yes
+c wait 0
diff --git a/cmd/dovecot-auth-cli/test_missing_socket.cmy b/cmd/dovecot-auth-cli/test_missing_socket.cmy
new file mode 100644
index 0000000..a677964
--- /dev/null
+++ b/cmd/dovecot-auth-cli/test_missing_socket.cmy
@@ -0,0 +1,8 @@
+
+c = ./dovecot-auth-cli .missingsocket exists username
+c <~ no: dial unix .missingsocket-userdb
+c wait 1
+
+c = ./dovecot-auth-cli .missingsocket auth username password
+c <~ no: dial unix .missingsocket-client
+c wait 1
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 95c3eb5..8ebc70f 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 	"time"
 
+	"blitiri.com.ar/go/chasquid/internal/dovecot"
 	"blitiri.com.ar/go/chasquid/internal/userdb"
 )
 
@@ -119,6 +120,7 @@ func check(t *testing.T, a *Authenticator, user, domain, passwd string, expect b
 
 func TestInterfaces(t *testing.T) {
 	var _ NoErrorBackend = userdb.New("/dev/null")
+	var _ Backend = dovecot.NewAuth("/dev/null", "/dev/null")
 }
 
 // Backend implementation for testing.
diff --git a/internal/dovecot/dovecot.go b/internal/dovecot/dovecot.go
new file mode 100644
index 0000000..9a7f26e
--- /dev/null
+++ b/internal/dovecot/dovecot.go
@@ -0,0 +1,276 @@
+// 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"
+	"os"
+	"strings"
+	"time"
+	"unicode"
+)
+
+// Default timeout 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)")
+)
+
+var defaultUserdbPaths = []string{
+	"/var/run/dovecot/auth-chasquid-userdb",
+	"/var/run/dovecot/auth-userdb",
+}
+
+var defaultClientPaths = []string{
+	"/var/run/dovecot/auth-chasquid-client",
+	"/var/run/dovecot/auth-client",
+}
+
+// Auth represents a particular Dovecot auth service to use.
+type Auth struct {
+	userdbAddr string
+	clientAddr 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 {
+	return &Auth{
+		userdbAddr: userdb,
+		clientAddr: client,
+		Timeout:    DefaultTimeout,
+	}
+}
+
+func (a *Auth) String() string {
+	return fmt.Sprintf("DovecotAuth(%q, %q)", a.userdbAddr, a.clientAddr)
+}
+
+// Check to see if this auth is valid (but may not be working).
+func (a *Auth) Check() error {
+	// We intentionally don't connect or complete any handshakes because
+	// dovecot may not be up yet, even thought it may be configured properly.
+	// Just check that the addresses are valid sockets.
+	if !isUnixSocket(a.userdbAddr) {
+		return fmt.Errorf("userdb is not an unix socket")
+	}
+	if !isUnixSocket(a.clientAddr) {
+		return fmt.Errorf("client is not an unix socket")
+	}
+
+	return nil
+}
+
+// Does user exist?
+func (a *Auth) Exists(user string) (bool, error) {
+	if !isUsernameSafe(user) {
+		return false, ErrUsernameNotSafe
+	}
+
+	conn, err := a.dial("unix", a.userdbAddr)
+	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"+user+"\t") {
+		return true, nil
+	} else if strings.HasPrefix(resp, "NOTFOUND\t") {
+		return false, nil
+	}
+	return false, fmt.Errorf("invalid response: %q", resp)
+}
+
+// Is the password valud for the user?
+func (a *Auth) Authenticate(user, passwd string) (bool, error) {
+	if !isUsernameSafe(user) {
+		return false, ErrUsernameNotSafe
+	}
+
+	conn, err := a.dial("unix", a.clientAddr)
+	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)
+}
+
+func (a *Auth) Reload() error {
+	return nil
+}
+
+func (a *Auth) dial(network, addr string) (*textproto.Conn, error) {
+	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 := fmt.Fprintf(conn.W, msg)
+	if err != nil {
+		return err
+	}
+
+	return conn.W.Flush()
+}
+
+// isUsernameSafe to use in the dovecot protocol?
+// Unfotunately 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
+}
+
+// Autodetect where the dovecot authentication paths are, and return an Auth
+// instance for them. If any of userdb or client are != "", they will be used
+// and not autodetected.
+func Autodetect(userdb, client string) *Auth {
+	// If both are given, no need to autodtect.
+	if userdb != "" && client != "" {
+		return NewAuth(userdb, client)
+	}
+
+	var userdbs, clients []string
+	if userdb != "" {
+		userdbs = append(userdbs, userdb)
+	}
+	if client != "" {
+		clients = append(clients, client)
+	}
+
+	if len(userdbs) == 0 {
+		userdbs = append(userdbs, defaultUserdbPaths...)
+	}
+
+	if len(clients) == 0 {
+		clients = append(clients, defaultClientPaths...)
+	}
+
+	// Go through each possiblity, return the first auth that works.
+	for _, u := range userdbs {
+		for _, c := range clients {
+			a := NewAuth(u, c)
+			if a.Check() == nil {
+				return a
+			}
+		}
+	}
+
+	return nil
+}
+
+func isUnixSocket(path string) bool {
+	fi, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+	return fi.Mode()&os.ModeSocket != 0
+}
diff --git a/internal/dovecot/dovecot_test.go b/internal/dovecot/dovecot_test.go
new file mode 100644
index 0000000..3fbba86
--- /dev/null
+++ b/internal/dovecot/dovecot_test.go
@@ -0,0 +1,111 @@
+package dovecot
+
+// The dovecot package is mainly tested via integration/external tests using
+// the dovecot-auth-cli tool. See cmd/dovecot-auth-cli for more details.
+// The tests here are more narrow and only test specific functionality that is
+// easier to cover from Go.
+
+import (
+	"net"
+	"testing"
+
+	"blitiri.com.ar/go/chasquid/internal/testlib"
+)
+
+func TestUsernameNotSafe(t *testing.T) {
+	a := NewAuth("/tmp/nothing", "/tmp/nothing")
+
+	cases := []string{
+		"a b", " ab", "ab ", "a\tb", "a\t", " ", "\t", "\t "}
+	for _, c := range cases {
+		ok, err := a.Authenticate(c, "passwd")
+		if ok || err != ErrUsernameNotSafe {
+			t.Errorf("Authenticate(%q, _): got %v, %v", c, ok, err)
+		}
+
+		ok, err = a.Exists(c)
+		if ok || err != ErrUsernameNotSafe {
+			t.Errorf("Exists(%q): got %v, %v", c, ok, err)
+		}
+	}
+}
+
+func TestAutodetect(t *testing.T) {
+	// If we give both parameters to autodetect, it should return a new Auth
+	// using them, even if they're not valid.
+	a := Autodetect("uDoesNotExist", "cDoesNotExist")
+	if a == nil {
+		t.Errorf("Autodetection with two params failed")
+	} else if *a != *NewAuth("uDoesNotExist", "cDoesNotExist") {
+		t.Errorf("Autodetection with two params: got %v", a)
+	}
+
+	// We override the default paths, so we can point the "defaults" to our
+	// test environment as needed.
+	defaultUserdbPaths = []string{"/dev/null"}
+	defaultClientPaths = []string{"/dev/null"}
+
+	// Autodetect failure: no valid sockets on the list.
+	a = Autodetect("", "")
+	if a != nil {
+		t.Errorf("Autodetection worked with only /dev/null, got %v", a)
+	}
+
+	// Create a temporary directory, and two sockets on it.
+	dir := testlib.MustTempDir(t)
+	defer testlib.RemoveIfOk(t, dir)
+
+	userdb := dir + "/userdb"
+	client := dir + "/client"
+
+	uL := mustListen(t, userdb)
+	cL := mustListen(t, client)
+
+	defaultUserdbPaths = append(defaultUserdbPaths, userdb)
+	defaultClientPaths = append(defaultClientPaths, client)
+
+	// Autodetect should work fine against open sockets.
+	a = Autodetect("", "")
+	if a == nil {
+		t.Errorf("Autodetection failed (open sockets)")
+	} else if a.userdbAddr != userdb || a.clientAddr != client {
+		t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}",
+			userdb, client, a.userdbAddr, a.clientAddr)
+	}
+
+	// TODO: Close the two sockets, and re-do the test from above: Autodetect
+	// should work fine against closed sockets.
+	// To implement this test, we should call SetUnlinkOnClose, but
+	// unfortunately that is only available in Go >= 1.8.
+	// We want to support Go 1.7 for a while as it is in Debian stable; once
+	// Debian stable moves on, we can implement this test easily.
+
+	// Autodetect should pick the suggestions passed as parameters (if
+	// possible).
+	defaultUserdbPaths = []string{"/dev/null"}
+	defaultClientPaths = []string{"/dev/null", client}
+	a = Autodetect(userdb, "")
+	if a == nil {
+		t.Errorf("Autodetection failed (single parameter)")
+	} else if a.userdbAddr != userdb || a.clientAddr != client {
+		t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}",
+			userdb, client, a.userdbAddr, a.clientAddr)
+	}
+
+	uL.Close()
+	cL.Close()
+}
+
+func mustListen(t *testing.T, path string) *net.UnixListener {
+	addr, err := net.ResolveUnixAddr("unix", path)
+	if err != nil {
+		t.Fatalf("failed to resolve unix addr %q: %v", path, err)
+	}
+
+	l, err := net.ListenUnix("unix", addr)
+	if err != nil {
+		t.Fatalf("failed to listen on %q: %v", path, err)
+	}
+
+	return l
+}