commit 7583dd64601bcc2973ff355bf18e4c09f471245e from: Oliver Lowe date: Wed Apr 13 05:47:27 2022 UTC wip 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))