diff --git a/handlers.go b/handlers.go index 45209a4..3b8fe77 100644 --- a/handlers.go +++ b/handlers.go @@ -23,6 +23,7 @@ type Claims struct { Role string Iat int64 Exp int64 + Ref string } func Handler() http.Handler { @@ -39,6 +40,7 @@ func Handler() http.Handler { "role": user.Role, "iat": currentTime.Unix(), "exp": currentTime.Add(time.Minute * 60 * 24).Unix(), + "ref": "", }, nil } @@ -78,6 +80,7 @@ func Handler() http.Handler { // Non-auth routes m.Handle("/authenticate", tokenHandler(j.GenerateToken())).Methods("POST") m.Handle("/users", errorHandler(handleCreater(userService))).Methods("POST") + m.Handle("/users/verify/{Nonce}", http.HandlerFunc(handleUserVerify)).Methods("GET") // Auth routes m.Handle("/users", j.Secure(errorHandler(handleLister(userService)), verifyClaims)).Methods("GET") diff --git a/helpers.go b/helpers.go index e0376fa..52a80de 100644 --- a/helpers.go +++ b/helpers.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "net/http" @@ -89,3 +91,12 @@ func verifyPassword(s string) (sevenOrMore, number, upper bool) { sevenOrMore = letters >= 7 return } + +func generateNonce() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/migrations/00001_AddUsers_down.sql b/migrations/00001_AddUsers_down.sql index b094f36..5687732 100644 --- a/migrations/00001_AddUsers_down.sql +++ b/migrations/00001_AddUsers_down.sql @@ -1,6 +1,10 @@ -- bactdb -- Matthew R Dillon +-- Need to include something to keep gomigrate happy. +-- SELECT 1; + DROP TABLE users; DROP TYPE e_roles; + diff --git a/migrations/00001_AddUsers_up.sql b/migrations/00001_AddUsers_up.sql index 0dc46b1..66f9fff 100644 --- a/migrations/00001_AddUsers_up.sql +++ b/migrations/00001_AddUsers_up.sql @@ -1,17 +1,23 @@ -- bactdb -- Matthew R Dillon -CREATE TYPE e_roles AS ENUM('R', 'W', 'A'); --- 'R': read-only, default --- 'W': read-write --- 'A': administrator +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'e_roles') THEN + CREATE TYPE e_roles AS ENUM('R', 'W', 'A'); + -- 'R': read-only, default + -- 'W': read-write + -- 'A': administrator + END IF; +END$$; -CREATE TABLE users ( +CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL NOT NULL, email CHARACTER VARYING(254) NOT NULL UNIQUE, password CHARACTER(60) NOT NULL, name TEXT NOT NULL, role e_roles DEFAULT 'R' NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL, diff --git a/migrations/00011_AddVerification_down.sql b/migrations/00011_AddVerification_down.sql new file mode 100644 index 0000000..a1da689 --- /dev/null +++ b/migrations/00011_AddVerification_down.sql @@ -0,0 +1,5 @@ +-- bactdb +-- Matthew R Dillon + +DROP TABLE verification; + diff --git a/migrations/00011_AddVerification_up.sql b/migrations/00011_AddVerification_up.sql new file mode 100644 index 0000000..0bde3ba --- /dev/null +++ b/migrations/00011_AddVerification_up.sql @@ -0,0 +1,13 @@ +-- bactdb +-- Matthew R Dillon + +CREATE TABLE verification ( + user_id BIGINT NOT NULL, + nonce CHARACTER(60) NOT NULL UNIQUE, + + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT verification_pkey PRIMARY KEY (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) +); + diff --git a/users.go b/users.go index 539588b..56bd2fd 100644 --- a/users.go +++ b/users.go @@ -4,10 +4,15 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" + "log" "net/http" "net/url" "regexp" + "github.com/gorilla/mux" + "github.com/lib/pq" + "golang.org/x/crypto/bcrypt" ) @@ -17,6 +22,8 @@ var ( ErrUserNotUpdated = errors.New("User not updated") ErrUserNotUpdatedJSON = newJSONError(ErrUserNotUpdated, http.StatusBadRequest) ErrInvalidEmailOrPassword = errors.New("Invalid email or password") + ErrEmailAddressTaken = errors.New("Email address already registered") + ErrEmailAddressTakenJSON = newJSONError(ErrEmailAddressTaken, http.StatusBadRequest) ) func init() { @@ -31,6 +38,7 @@ type User struct { Password string `db:"password" json:"password,omitempty"` Name string `db:"name" json:"name"` Role string `db:"role" json:"role"` + Verified bool `db:"verified" json:"-"` CreatedAt NullTime `db:"created_at" json:"createdAt"` UpdatedAt NullTime `db:"updated_at" json:"updatedAt"` DeletedAt NullTime `db:"deleted_at" json:"deletedAt"` @@ -126,7 +134,10 @@ func (u UserService) list(val *url.Values) (entity, *appError) { users := make(Users, 0) sql := `SELECT id, email, 'password' AS password, name, role, - created_at, updated_at, deleted_at FROM users;` + created_at, updated_at, deleted_at + FROM users + WHERE verified IS NOT FALSE + AND deleted_at IS NULL;` if err := DBH.Select(&users, sql); err != nil { return nil, newJSONError(err, http.StatusInternalServerError) } @@ -136,7 +147,11 @@ func (u UserService) list(val *url.Values) (entity, *appError) { func (u UserService) get(id int64, genus string) (entity, *appError) { var user User q := `SELECT id, email, 'password' AS password, name, role, - created_at, updated_at, deleted_at FROM users WHERE id=$1;` + created_at, updated_at, deleted_at + FROM users + WHERE id=$1 + AND verified IS NOT FALSE + AND deleted_at IS NULL;` if err := DBH.SelectOne(&user, q, id); err != nil { if err == sql.ErrNoRows { return nil, ErrUserNotFoundJSON @@ -175,10 +190,29 @@ func (u UserService) create(e *entity, claims Claims) *appError { } user.Password = string(hash) user.Role = "R" + user.Verified = false if err := DBH.Insert(user); err != nil { + if err, ok := err.(*pq.Error); ok { + if err.Code == "23505" { + return ErrEmailAddressTakenJSON + } + } return newJSONError(err, http.StatusInternalServerError) } + + user.Password = "password" // don't want to send the hashed PW back to the client + + q := `INSERT INTO verification (user_id, nonce, created_at) VALUES ($1, $2, $3);` + nonce, err := generateNonce() + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + _, err = DBH.Exec(q, user.Id, nonce, ct) + if err != nil { + return newJSONError(err, http.StatusInternalServerError) + } + return nil } @@ -207,3 +241,46 @@ func dbGetUserByEmail(email string) (*User, error) { } return &user, nil } + +func handleUserVerify(w http.ResponseWriter, r *http.Request) { + nonce := mux.Vars(r)["Nonce"] + q := `SELECT user_id FROM verification WHERE nonce=$1;` + + var user_id int64 + if err := DBH.SelectOne(&user_id, q, nonce); err != nil { + log.Printf("%+v", err) + return + } + + if user_id == 0 { + fmt.Fprintln(w, "NOT FOUND/EXPIRED") + return + } + + var user User + if err := DBH.Get(&user, user_id); err != nil { + fmt.Printf("%+v", err) + return + } + + user.UpdatedAt = currentTime() + user.Verified = true + + count, err := DBH.Update(&user) + if err != nil { + fmt.Printf("%+v", err) + return + } + if count != 1 { + fmt.Printf("%+v", "hmm") + return + } + + q = `DELETE FROM verification WHERE user_id=$1;` + _, err = DBH.Exec(q, user_id) + if err != nil { + log.Printf("%+v", err) + } + + fmt.Fprintln(w, user_id) +}