Commit Diff


commit - 861fc513c8968adc6aac3ab85377d3e6b9054705
commit + 7583dd64601bcc2973ff355bf18e4c09f471245e
blob - 7587d15c5f7a44e4d14b5ded3d1acaacd9d152f6
blob + df1f9d043def3d6b98d774a67bec68a14dbc6267
--- client.go
+++ client.go
@@ -1,6 +1,9 @@
 package mailmux
 
 import (
+	"bytes"
+	"encoding/json"
+	"errors"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -8,25 +11,59 @@ import (
 
 const apiurl = "https://mailmux.net/v1/aliases"
 
+const jsonContentType = "application/json"
+
 type Client struct {
 	*http.Client
+	addr string
 	user string
 	token string
 }
 
-func NewClient(user, token string) *Client {
-	return &Client{http.DefaultClient, user, token}
+func Dial(uri, user, token string) *Client {
+	return &Client{http.DefaultClient, uri, user, token}
 }
 
+func (c *Client) Register(username, password string) error {
+	mcall := &Mcall{
+		Type: Tregister,
+		Username: username,
+		Password: password,
+	}
+
+	body := &bytes.Buffer{}
+	if err := json.NewEncoder(body).Encode(mcall); err != nil {
+		return fmt.Errorf("register %s: %v", username, err)
+	}
+	resp, err := c.Post(c.addr + "/register", jsonContentType, body)
+	if err != nil {
+		return fmt.Errorf("register %s: %v", username, err)
+	}
+	if resp.StatusCode == http.StatusOK {
+		return nil
+	}
+
+	defer resp.Body.Close()
+	rerror, err := ParseMcall(resp.Body)
+	if err != nil {
+		return fmt.Errorf("register %s: parse response: %v", username, err)
+	}
+	return fmt.Errorf("register %s: %s", username, rerror.Error)
+}
+
 func (c *Client) NewAlias() (Alias, error) {
 	v := url.Values{}
 	v.Add("username", c.user)
 	v.Add("token", c.token)
-	resp, err := http.PostForm(apiurl, v)
+	resp, err := http.PostForm(c.addr + "/aliases", v)
 	if err != nil {
 		return Alias{}, err
 	}
 	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return Alias{}, errors.New(resp.Status)
+	}
+
 	return Alias{}, nil
 }
 
blob - cf7c3b7002ca03c91b23889a340c6b387bd92778
blob + 95eb5dc5f0570ad7be1eab427a0896ff34a8f85c
--- cmd/mailmux/mailmux.go
+++ cmd/mailmux/mailmux.go
@@ -2,12 +2,13 @@ package main
 
 import (
 	"encoding/json"
-	"errors"
 	"fmt"
 	"log"
+	"math/rand"
 	"net/http"
 	"os"
 	"path"
+	"time"
 
 	mailmux "mailmux.net"
 	"mailmux.net/aliases"
@@ -20,49 +21,6 @@ type server struct {
 	users mailmux.UserStore
 }
 
-func (srv *server) registerUser(name string, pw mailmux.Password) error {
-	_, err := srv.users.Lookup(name)
-	if err == nil {
-		return mailmux.ErrUserExist
-	}
-	if errors.Is(err, mailmux.ErrUnknownUser) {
-		return srv.users.Change(name, pw)
-	}
-	return err
-}
-
-func (srv *server) registerHandler(w http.ResponseWriter, req *http.Request) {
-	if req.Method != http.MethodPost {
-		http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
-		return
-	}
-	if err := req.ParseForm(); err != nil {
-		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
-		log.Println(err)
-		return
-	}
-
-	username := req.PostForm.Get("username")
-	if username == "" {
-		http.Error(w, "empty username", http.StatusBadRequest)
-		return
-	}
-
-	pw := req.PostForm.Get("password")
-	if pw == "" {
-		http.Error(w, "empty password", http.StatusBadRequest)
-		return
-	}
-	// TODO hash password
-	password := mailmux.Password(pw)
-
-	err := srv.registerUser(username, password)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		log.Println(err)
-	}
-}
-
 func (srv *server) aliasHandler(w http.ResponseWriter, req *http.Request) {
 	if err := req.ParseForm(); err != nil {
 		w.WriteHeader(http.StatusBadRequest)
@@ -151,6 +109,19 @@ func (srv *server) deleteAlias(w http.ResponseWriter, 
 	http.Error(w, msg, http.StatusNotFound)
 }
 
+// rerror replies to the HTTP request with a JSON-encoded rerror message
+// with its Error field set to errormsg.
+// Just like http.Error, callers should ensure no further writes are done to w.
+func rerror(w http.ResponseWriter, errormsg string, status int) {
+	mcall := &mailmux.Mcall{Type: mailmux.Rerror, Error: errormsg}
+	if err := json.NewEncoder(w).Encode(mcall); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(status)
+	return
+}
+
 func main() {
 	cdir, err := os.UserCacheDir()
 	if err != nil {
@@ -163,12 +134,13 @@ func main() {
 		os.Exit(1)
 	}
 
+	srv := &server{}
 	db, err := mailmux.OpenUserDB(path.Join(cdir, "/mailmux/db"), ticketDir)
 	if err != nil {
 		fmt.Fprintln(os.Stderr, err)
 		os.Exit(1)
 	}
-	srv := &server{users: db}
+	srv.users = db
 
 	srv.aliaspath = "/tmp/aliases"
 	srv.aliases, err = aliases.Load(srv.aliaspath)
blob - /dev/null
blob + 52f9c160dcf7b2ab08ae17f9b0923353ce2d9b0f (mode 644)
--- /dev/null
+++ cmd/mailmux/mailmux_test.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+	"net"
+	"net/http"
+	"os"
+	"path"
+	"testing"
+
+	mailmux "mailmux.net"
+)
+
+func newTestServerClient(t *testing.T) *mailmux.Client {
+	dir, err := os.MkdirTemp("", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	db, err := mailmux.CreateUserDB(path.Join(dir, "db"), dir)
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := &server{
+		users:     db,
+		aliaspath: path.Join(dir, "aliases"),
+		seedfile:  "/usr/share/dict/words",
+	}
+
+	ln, err := net.Listen("tcp", "[::1]:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	http.HandleFunc("/register", srv.registerHandler)
+	http.HandleFunc("/aliases", srv.aliasHandler)
+	go func() {
+		t.Fatal(http.Serve(ln, nil))
+	}()
+
+	return mailmux.Dial("http://" + ln.Addr().String(), "test", "test")
+}
+
+func TestBadRegistration(t *testing.T) {
+	client := newTestServerClient(t)
+	registrations := map[string]string{
+		"djfkjskdjf": "dfjkdkfjsd",
+		"": "asdfgjkl",
+		"fjdklskjdsf": "",
+		"@@@@": "dfjksjkdf",
+		"foo@example.com": "",
+	}
+	for username, password := range registrations {
+		err := client.Register(username, password)
+		if err == nil {
+			t.Errorf("nil error on bad registration username %q password %q", username, password)
+		}
+	}
+}
blob - /dev/null
blob + 46f5ee368b9a28a1cf6f475ad9dfa4a501d8d6e9 (mode 644)
--- /dev/null
+++ cmd/mailmux/register.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	mailmux "mailmux.net"
+)
+
+func (srv *server) registerUser(name string, pw mailmux.Password) error {
+	_, err := srv.users.Lookup(name)
+	if err == nil {
+		return mailmux.ErrUserExist
+	}
+	if errors.Is(err, mailmux.ErrUnknownUser) {
+		return srv.users.Change(name, pw)
+	}
+	return err
+}
+
+func (srv *server) registerHandler(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		rerror(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+		return
+	}
+
+	var mcall mailmux.Mcall
+	if err := json.NewDecoder(req.Body).Decode(&mcall); err != nil {
+		rerror(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	if mcall.Type != mailmux.Tregister {
+		s := fmt.Sprintf("mcall type %d is not Tregister", mcall.Type)
+		rerror(w, s, http.StatusBadRequest)
+		return
+	}
+	if mcall.Username == "" {
+		rerror(w, "empty username", http.StatusBadRequest)
+		return
+	}
+	if mcall.Password == "" {
+		rerror(w, "empty password", http.StatusBadRequest)
+		return
+	}
+
+	err := srv.registerUser(mcall.Username, mailmux.Password(mcall.Password))
+	if err != nil {
+		rerror(w, err.Error(), http.StatusInternalServerError)
+	}
+}
blob - 1cd07436a3759ef0d35ff6eb5dbb6b2afbfe336a
blob + 6b7bc2b7ecae600709724f269936478d9658cf2b
--- go.mod
+++ go.mod
@@ -2,4 +2,7 @@ module mailmux.net
 
 go 1.17
 
-require github.com/mattn/go-sqlite3 v1.14.12 // indirect
+require (
+	github.com/mattn/go-sqlite3 v1.14.12 // indirect
+	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect
+)
blob - a7151d6366cd2432431a58124bb54ea304dd7bf2
blob + 0dd4d68af6fa5591ec9e1e2767199c8a91538db2
--- go.sum
+++ go.sum
@@ -1,2 +1,4 @@
 github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
 github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
blob - /dev/null
blob + 878bf43ea9dab8f037a8240bb85d7449f13ffd31 (mode 644)
--- /dev/null
+++ mcall.go
@@ -0,0 +1,54 @@
+package mailmux
+
+import (
+	"encoding/json"
+	"errors"
+	"io"
+	"time"
+)
+
+const (
+	Tregister = 1 + iota
+	Rregister
+	Rerror
+	Tnew
+	Rnew
+	Tlist
+	Rlist
+	Tremove
+)
+
+// An Mcall is a message passed between mailmux client and servers.
+// Operations requested by clients are T-messages (such as Tregister).
+// Servers respond with the corresponding R-message (such as Rregister) or Rerror to inform the client,
+// with a diagnostic message, that the request was not completed successfully.
+// This design is loosely based on the Plan 9 network file protocol 9P.
+type Mcall struct {
+	Type uint
+	Username string // Tregister, Rregister
+	Password string // Tregister
+	Error string // Rerror
+	Aliases []Alias // Rnew, Rlist, Tremove
+	Expiry time.Time // Tnew, Rnew
+}
+
+// ParseMcall parses and validates one Mcall from r.
+func ParseMcall(r io.Reader) (*Mcall, error) {
+	var mc Mcall
+	if err := json.NewDecoder(r).Decode(&mc); err != nil {
+		return nil, err
+	}
+	if mc.Username == "" {
+		return nil, errors.New("empty username")
+	}
+	if mc.Password == "" {
+		return nil, errors.New("empty password")
+	}
+	if mc.Type != Rerror && mc.Error != "" {
+		return nil, errors.New("non-empty error field")
+	}
+	if mc.Type == Rerror && mc.Error == "" {
+		return nil, errors.New("empty error message")
+	}
+	return &mc, nil
+}
blob - 86fb14489e0d0003570103a71faaf8434175ff0c
blob + 8b4fa6214d6f6971d2f7bf401433e257f6da7426
--- user.go
+++ user.go
@@ -4,7 +4,9 @@ import (
 	"errors"
 )
 
+// User represents a mailmux user.
 type User struct {
+	// The name should be a valid email address.
 	name     string
 	password Password
 }
blob - 80ecd2b252ac071b45975e0e83cff33f46e62d36
blob + 4e32bae9d0de09c2bf5279927a10eaab881be5b7
--- userdb.go
+++ userdb.go
@@ -4,10 +4,12 @@ import (
 	"database/sql"
 	"errors"
 	"fmt"
+	"net/mail"
 	"os"
 	"path"
 
 	_ "github.com/mattn/go-sqlite3"
+	"golang.org/x/crypto/bcrypt"
 )
 
 // UserDB is an implementation of UserStore backed by a SQLite3 database.
@@ -18,6 +20,24 @@ type UserDB struct {
 	TicketDir string
 }
 
+// CreateUserDB creates the named user database file and ticket directory.
+// If the database file already exists, it is truncated. If successfullly created,
+// the database's tables are initialised so the database is ready to use.
+func CreateUserDB(name, ticketDir string) (*UserDB, error) {
+	_, err := os.Create(name)
+	if err != nil {
+		return nil, err
+	}
+	if err := os.MkdirAll(ticketDir, 0666); err != nil {
+		return nil, fmt.Errorf("create user db: %w", err)
+	}
+	db, err := OpenUserDB(name, ticketDir)
+	if err != nil {
+		return nil, fmt.Errorf("create user db: %w", err)
+	}
+	return db, db.initialise()
+}
+
 // OpenUserDB opens the named user database file and ticket directory.
 func OpenUserDB(name, dir string) (*UserDB, error) {
 	db, err := sql.Open("sqlite3", name)
@@ -27,6 +47,15 @@ func OpenUserDB(name, dir string) (*UserDB, error) {
 	return &UserDB{db, dir}, db.Ping()
 }
 
+func (db *UserDB) initialise() error {
+	stmt := `CREATE TABLE IF NOT EXISTS users (
+	username TEXT PRIMARY KEY,
+	password BLOB NOT NULL
+);`
+	_, err := db.Exec(stmt)
+	return err
+}
+
 func (db *UserDB) Lookup(name string) (User, error) {
 	var u User
 	row := db.QueryRow("SELECT username, password FROM users WHERE username = ?", name)
@@ -40,10 +69,22 @@ func (db *UserDB) Lookup(name string) (User, error) {
 }
 
 func (db *UserDB) add(username string, pw Password) error {
-	_, err := db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, pw)
+	addr, err := mail.ParseAddress(username)
 	if err != nil {
+		return fmt.Errorf("add %s: invalid username: %w", username, err)
+	}
+	if addr.Name != "" {
+		return fmt.Errorf("add %s: proper name present", username)
+	}
+
+	hashed, err := bcrypt.GenerateFromPassword(pw, bcrypt.DefaultCost)
+	if err != nil {
 		return fmt.Errorf("add %s: %w", username, err)
 	}
+	_, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", username, hashed)
+	if err != nil {
+		return fmt.Errorf("add %s: %w", username, err)
+	}
 	if err := createTicket(db.TicketDir, username, pw); err != nil {
 		return fmt.Errorf("add %s: create ticket: %w", username, err)
 	}
@@ -59,10 +100,14 @@ func (db *UserDB) Change(name string, new Password) er
 		return fmt.Errorf("change %s: %w", name, err)
 	}
 
-	_, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", new, name)
+	hashed, err := bcrypt.GenerateFromPassword(new, bcrypt.DefaultCost)
 	if err != nil {
 		return fmt.Errorf("change %s: %w", name, err)
 	}
+	_, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", hashed, name)
+	if err != nil {
+		return fmt.Errorf("change %s: %w", name, err)
+	}
 	return nil
 }
 
@@ -78,6 +123,17 @@ func (db *UserDB) Delete(name string) error {
 	return nil
 }
 
+func (db *UserDB) Authenticate(name string, pw Password) error {
+	u, err := db.Lookup(name)
+	if err != nil {
+		return fmt.Errorf("authenticate %s: %w", name, err)
+	}
+	if err := bcrypt.CompareHashAndPassword(u.password, pw); err != nil {
+		return fmt.Errorf("authenticate %s: %w", name, err)
+	}
+	return nil
+}
+
 // TODO tickets aren't implemented yet
 func createTicket(dir, username string, pw Password) error {
 	f, err := os.Create(path.Join(dir, username))