From fe0cdbd3cb93bedbcc66604cf0e4e1196bcd8fda Mon Sep 17 00:00:00 2001 From: Dustin Pianalto Date: Tue, 16 Nov 2021 21:06:24 -0900 Subject: [PATCH] Add user management routes --- cmd/quartermaster/main.go | 2 +- go.mod | 2 + go.sum | 4 + internal/postgres/database.go | 2 + .../000003_add_users_table.down.sql | 10 ++ .../migrations/000003_add_users_table.up.sql | 16 ++ internal/postgres/users.go | 37 +++++ pkg/api/api.go | 5 +- pkg/api/home.go | 7 - pkg/api/users/router.go | 11 ++ pkg/api/users/views.go | 143 ++++++++++++++++++ pkg/services/services.go | 2 + users.go | 22 +++ 13 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 internal/postgres/migrations/000003_add_users_table.down.sql create mode 100644 internal/postgres/migrations/000003_add_users_table.up.sql create mode 100644 internal/postgres/users.go create mode 100644 pkg/api/users/router.go create mode 100644 pkg/api/users/views.go create mode 100644 users.go diff --git a/cmd/quartermaster/main.go b/cmd/quartermaster/main.go index 7f32c2d..f476f1e 100644 --- a/cmd/quartermaster/main.go +++ b/cmd/quartermaster/main.go @@ -14,7 +14,7 @@ import ( ) func main() { - var migrationsVersion uint = 2 // Update when there is a new migration + var migrationsVersion uint = 3 // Update when there is a new migration postgres.ConnectDatabase(os.Getenv("DATABASE_URL"), migrationsVersion) services.InitServices() diff --git a/go.mod b/go.mod index 3887853..aa6ad13 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.17 require ( github.com/gobuffalo/here v0.6.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-migrate/migrate/v4 v4.15.1 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -11,4 +12,5 @@ require ( github.com/lib/pq v1.10.4 // indirect github.com/markbates/pkger v0.17.1 // indirect go.uber.org/atomic v1.6.0 // indirect + golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 // indirect ) diff --git a/go.sum b/go.sum index d38a678..9de5316 100644 --- a/go.sum +++ b/go.sum @@ -397,6 +397,8 @@ github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5 github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA= github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk= github.com/golang-migrate/migrate/v4 v4.15.1 h1:Sakl3Nm6+wQKq0Q62tpFMi5a503bgGhceo2icrgQ9vM= @@ -907,6 +909,8 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 h1:5QRxNnVsaJP6NAse0UdkRgL3zHMvCRRkrDVLNdNpdy4= +golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/internal/postgres/database.go b/internal/postgres/database.go index fb67889..93b97fe 100644 --- a/internal/postgres/database.go +++ b/internal/postgres/database.go @@ -21,6 +21,7 @@ var ( VitaminService quartermaster.VitaminService GroupService quartermaster.GroupService CategoryService quartermaster.CategoryService + UserService quartermaster.UserService ) func ConnectDatabase(dbConnString string, version uint) { @@ -66,4 +67,5 @@ func initServices(db *sql.DB) { VitaminService = vitaminService{db: db} GroupService = groupService{db: db} CategoryService = categoryService{db: db} + UserService = userService{db: db} } diff --git a/internal/postgres/migrations/000003_add_users_table.down.sql b/internal/postgres/migrations/000003_add_users_table.down.sql new file mode 100644 index 0000000..ef16cd3 --- /dev/null +++ b/internal/postgres/migrations/000003_add_users_table.down.sql @@ -0,0 +1,10 @@ +BEGIN; + +ALTER TABLE items DROP COLUMN owner_id ; +ALTER TABLE locations DROP COLUMN owner_id; +ALTER TABLE groups DROP COLUMN owner_id; +ALTER TABLE categories DROP COLUMN owner_id; + +DROP TABLE IF EXISTS users; + +COMMIT; \ No newline at end of file diff --git a/internal/postgres/migrations/000003_add_users_table.up.sql b/internal/postgres/migrations/000003_add_users_table.up.sql new file mode 100644 index 0000000..f4f8d1e --- /dev/null +++ b/internal/postgres/migrations/000003_add_users_table.up.sql @@ -0,0 +1,16 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY , + username VARCHAR(255), + password VARCHAR(60), + CONSTRAINT uniq_username + UNIQUE (username) +); + +ALTER TABLE items ADD COLUMN owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE; +ALTER TABLE locations ADD COLUMN owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE; +ALTER TABLE groups ADD COLUMN owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE; +ALTER TABLE categories ADD COLUMN owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE; + +COMMIT; \ No newline at end of file diff --git a/internal/postgres/users.go b/internal/postgres/users.go new file mode 100644 index 0000000..edde1be --- /dev/null +++ b/internal/postgres/users.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "database/sql" + + "github.com/dustinpianalto/quartermaster" +) + +type userService struct { + db *sql.DB +} + +func (s userService) User(username string) (*quartermaster.User, error) { + var user quartermaster.User + queryString := "SELECT id, username, password FROM users WHERE username = $1" + row := s.db.QueryRow(queryString, username) + err := row.Scan(&user.ID, &user.Username, &user.Password) + return &user, err +} + +func (s userService) AddUser(u *quartermaster.User) (*quartermaster.User, error) { + queryString := "INSERT INTO users (username, password) VALUES ($1, $2) RETURNING id" + err := s.db.QueryRow(queryString, u.Username, u.Password).Scan(&u.ID) + return u, err +} + +func (s userService) RemoveUser(u *quartermaster.User) error { + queryString := "DELETE FROM users WHERE id = $1" + _, err := s.db.Exec(queryString, u.ID) + return err +} + +func (s userService) UpdateUser(u *quartermaster.User) error { + queryString := "UPDATE users SET password = $1 WHERE id = $2" + _, err := s.db.Exec(queryString, u.Password, u.ID) + return err +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 42360cc..200fe63 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -1,12 +1,15 @@ package api import ( + "github.com/dustinpianalto/quartermaster/internal/utils" + "github.com/dustinpianalto/quartermaster/pkg/api/users" "github.com/gorilla/mux" ) func GetRouter() *mux.Router { router := mux.NewRouter().StrictSlash(true) - router.HandleFunc("/", homePage).Methods("GET") + router.HandleFunc("/", healthcheck).Methods("GET") router.HandleFunc("/healthcheck", healthcheck).Methods("GET") + utils.Mount(router, "/users", users.GetRouter()) return router } diff --git a/pkg/api/home.go b/pkg/api/home.go index 3561bd4..1129740 100644 --- a/pkg/api/home.go +++ b/pkg/api/home.go @@ -6,13 +6,6 @@ import ( "net/http" ) -func homePage(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, "{\"status\": \"success\"}") - log.Println("{\"endpoint\": \"/\", \"status\": \"success\"}") -} - func healthcheck(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") diff --git a/pkg/api/users/router.go b/pkg/api/users/router.go new file mode 100644 index 0000000..b54795b --- /dev/null +++ b/pkg/api/users/router.go @@ -0,0 +1,11 @@ +package users + +import "github.com/gorilla/mux" + +func GetRouter() *mux.Router { + router := mux.NewRouter().StrictSlash(true) + router.HandleFunc("/login", login).Methods("POST") + router.HandleFunc("/register", register).Methods("POST") + router.HandleFunc("/refresh", refresh).Methods("POST") + return router +} diff --git a/pkg/api/users/views.go b/pkg/api/users/views.go new file mode 100644 index 0000000..9de6119 --- /dev/null +++ b/pkg/api/users/views.go @@ -0,0 +1,143 @@ +package users + +import ( + "encoding/json" + "log" + "net/http" + "os" + "time" + + "github.com/dustinpianalto/quartermaster" + "github.com/dustinpianalto/quartermaster/pkg/services" + "github.com/golang-jwt/jwt" + "golang.org/x/crypto/bcrypt" +) + +var jwtKey = []byte(os.Getenv("JWT_KEY")) + +func login(w http.ResponseWriter, r *http.Request) { + var userReq quartermaster.User + err := json.NewDecoder(r.Body).Decode(&userReq) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Println(err) + return + } + + user, err := services.UserService.User(userReq.Username) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + log.Println(err) + return + } + + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(userReq.Password)) != nil { + w.WriteHeader(http.StatusUnauthorized) + log.Println(err) + return + } + + expires := time.Now().Add(10 * time.Minute) + claims := &quartermaster.Claims{ + ID: user.ID, + Username: user.Username, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expires.Unix(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: tokenString, + Expires: expires, + }) + + http.Redirect(w, r, "/", http.StatusFound) // Redirect with 302 +} + +func register(w http.ResponseWriter, r *http.Request) { + var user *quartermaster.User + err := json.NewDecoder(r.Body).Decode(&user) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + p, err := bcrypt.GenerateFromPassword([]byte(user.Password), 5) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + user.Password = string(p) + + user, err = services.UserService.AddUser(user) + if err != nil { + w.WriteHeader(http.StatusConflict) + log.Println(err) + return + } + + http.Redirect(w, r, "/login", http.StatusFound) +} + +func refresh(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie("token") + if err != nil { + if err == http.ErrNoCookie { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusBadRequest) + return + } + tknStr := c.Value + claims := &quartermaster.Claims{} + tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (interface{}, error) { + return jwtKey, nil + }) + if err != nil { + if err == jwt.ErrSignatureInvalid { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusBadRequest) + return + } + if !tkn.Valid { + w.WriteHeader(http.StatusUnauthorized) + return + } + + // We ensure that a new token is not issued until enough time has elapsed + // In this case, a new token will only be issued if the old token is within + // 2 minutes of expiry. Otherwise, return a bad request status + if time.Until(time.Unix(claims.StandardClaims.ExpiresAt, 0)) >= 2*time.Minute { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Now, create a new token for the current use, with a renewed expiration time + expirationTime := time.Now().Add(10 * time.Minute) + claims.StandardClaims.ExpiresAt = expirationTime.Unix() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Set the new token as the users `token` cookie + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: tokenString, + Expires: expirationTime, + }) +} diff --git a/pkg/services/services.go b/pkg/services/services.go index 3711fbe..404a91e 100644 --- a/pkg/services/services.go +++ b/pkg/services/services.go @@ -14,6 +14,7 @@ var ( VitaminService quartermaster.VitaminService GroupService quartermaster.GroupService CategoryService quartermaster.CategoryService + UserService quartermaster.UserService ) func InitServices() { @@ -23,5 +24,6 @@ func InitServices() { VitaminService = postgres.VitaminService GroupService = postgres.GroupService CategoryService = postgres.CategoryService + UserService = postgres.UserService log.Println("Services Initialized") } diff --git a/users.go b/users.go new file mode 100644 index 0000000..297cb0f --- /dev/null +++ b/users.go @@ -0,0 +1,22 @@ +package quartermaster + +import "github.com/golang-jwt/jwt" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` +} + +type UserService interface { + User(string) (*User, error) + AddUser(*User) (*User, error) + RemoveUser(*User) error + UpdateUser(*User) error +} + +type Claims struct { + ID int `json:"id"` + Username string `json:"username"` + jwt.StandardClaims +}