package mailmux 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. // Users are fully authenticated after confirming ownership of their // email address by supplying a matching ticket to a generated ticket file. type UserDB struct { *sql.DB 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) if err != nil { return nil, err } stmt := `CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, password BLOB NOT NULL );` if _, err := db.Exec(stmt); err != nil { return nil, err } 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) if err := row.Scan(&u.name, &u.password); err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, ErrUnknownUser } return User{}, err } return u, nil } func (db *UserDB) add(username string, pw Password) error { 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) } return nil } func (db *UserDB) Change(name string, new Password) error { _, err := db.Lookup(name) if errors.Is(err, ErrUnknownUser) { return db.add(name, new) } if err != nil { return fmt.Errorf("lookup %s: %w", name, err) } hashed, err := bcrypt.GenerateFromPassword(new, bcrypt.DefaultCost) if err != nil { return fmt.Errorf("generate hash: %w", err) } _, err = db.Exec("UPDATE users SET password = ? WHERE username = ?", hashed, name) return err } func (db *UserDB) Delete(name string) error { _, err := db.Lookup(name) if err != nil { return fmt.Errorf("delete %s: %w", name, err) } _, err = db.Exec("DELETE FROM users WHERE username = ?", name) if err != nil { return fmt.Errorf("delete %s: %w", name, err) } 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)) if err != nil { return err } defer f.Close() // TODO hash username, password, random bytes // _, err := f.Write(b) if _, err := f.Write(testTicket); err != nil { return err } return nil }