From e08adc96ae8345bbae1a73b46537f5db3b531e69 Mon Sep 17 00:00:00 2001 From: Abdulmujeeb Raji Date: Wed, 26 Jul 2023 21:16:25 +0100 Subject: [PATCH] feat(backend): Create Users and Get JWT Introducing a unified signup and login API. You can post to the /user route with credentials (so far just name and password), and if the user doesn't exist, we create the user and return a token. If the user does exist, if the password inputted matches the database password, we return a token for that user, else we return an error. i've never wanted to kms more during a programming session --- backend/.gitignore | 2 + backend/README.md | 15 ++++++ backend/go.mod | 7 +++ backend/go.sum | 8 +++ backend/main.go | 48 +++++++++-------- backend/sql/init.sql | 4 ++ backend/user.go | 126 +++++++++++++++++++++++++++++++++++++++++++ backend/util.go | 16 ++++++ 8 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/README.md create mode 100644 backend/go.sum create mode 100644 backend/sql/init.sql create mode 100644 backend/user.go create mode 100644 backend/util.go diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8815028 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,2 @@ +*.db +.env \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..82ebe32 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +# Backend Usage + +In order for this to work, you need to have [sqlite3](https://sqlite.org) +installed on your system. Once you do, make a databse called `users.db` +and initialize it with the `sql/init.sql` script: + +```sh +$ cat sql/init.sql | sqlite3 users.db +``` + +You also need to have a `.env` file in this folder, with the following options specified + +- JWT_SECRET: Used to encrypt tokens for user auth. Must be provided. Should +be cryptographically secure +- PORT: Optionally replace the default port of 7741 \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index fc5e5fc..ca44bc6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,10 @@ module github.com/devraza/ambition/backend go 1.20 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.17 + golang.org/x/crypto v0.11.0 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..97fb814 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,8 @@ +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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= diff --git a/backend/main.go b/backend/main.go index 78e2457..8a1f6ab 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,37 +2,39 @@ package main import ( "log" - "net" + "net/http" "os" ) -const ( - serverAddress = "localhost:7764" -) +type App struct { + UserHandler *UserHandler +} + +func (h *App) ServeHTTP(res http.ResponseWriter, req *http.Request) { + var head string + head, req.URL.Path = ShiftPath(req.URL.Path) + switch head { + case "user": + h.UserHandler.Handle(res, req) + default: + http.Error(res, "Not Found", http.StatusNotFound) + } +} func main() { - log.Println("Ambition going strong at", serverAddress) - - listener, err := net.Listen("tcp", serverAddress) + user_handler, err := NewUserHandler() if err != nil { - log.Fatalln("Failed to initialise TCP listener", err) + log.Fatalln(err) } - defer listener.close() - for { - conn, err := listener.Accept() - if err != nil { - log.Println("Failed to accept connection:", err) - continue - } - - // Concurrency FTW - go handleConnection(conn) + a := &App{ + UserHandler: user_handler, } -} -func handleConnection(conn net.Conn) { - defer conn.Close() - - // TODO implement actual server. Waiting on frontend for this + port := os.Getenv("PORT") + if port == "" { + port = "7741" + } + log.Println("Ambition going strong at port 7741") + http.ListenAndServe(":"+port, a) } diff --git a/backend/sql/init.sql b/backend/sql/init.sql new file mode 100644 index 0000000..355fe86 --- /dev/null +++ b/backend/sql/init.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS users ( + name TEXT NOT NULL, + pwdhash TEXT NOT NULL +); \ No newline at end of file diff --git a/backend/user.go b/backend/user.go new file mode 100644 index 0000000..8d6db05 --- /dev/null +++ b/backend/user.go @@ -0,0 +1,126 @@ +package main + +import ( + "errors" + "fmt" + "io" + + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "net/http" + + "github.com/golang-jwt/jwt" + "golang.org/x/crypto/bcrypt" + + "database/sql" + _ "github.com/mattn/go-sqlite3" +) + +type UserHandler struct { + db *sql.DB + jwt_secret *ecdsa.PrivateKey +} + +type UserRequest struct { + Name string `json:"name"` + Password string `json:"password"` +} + +func NewUserHandler() (*UserHandler, error) { + db, err := sql.Open("sqlite3", "users.db") + if err != nil { + return nil, err + } + + jwt_secret, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + + return &UserHandler{ + db: db, + jwt_secret: jwt_secret, + }, nil +} + +func (h *UserHandler) Handle(res http.ResponseWriter, req *http.Request) { + switch req.Method { + case "POST": + h.createUser(res, req) + case "PUT": + h.updateUser(res, req) + case "DELETE": + h.deleteUser(res, req) + default: + http.Error(res, "Only POST, PUT, and DELETE are valid methods", http.StatusMethodNotAllowed) + } +} + +// NOTE(midnadimple): This function could be considered to do too much stuff, but +// I think this is the best implementation +func (h *UserHandler) createUser(res http.ResponseWriter, req *http.Request) { + // Can't unmarshal the actual req.Body so must read first + body, err := io.ReadAll(req.Body) + if err != nil { + http.Error(res, fmt.Sprintf("user: failed to read request (%s)", err), http.StatusBadRequest) + return + } + + var user_request UserRequest + if err := json.Unmarshal(body, &user_request); err != nil { + http.Error(res, "user: json request body doesn't match schema", http.StatusBadRequest) + return + } + + name := user_request.Name + password := []byte(user_request.Password) + + // Password checks + // ------------------- + row := h.db.QueryRow("SELECT pwdhash FROM users WHERE name=?", name) + var db_pwdhash string + + if err = row.Scan(&db_pwdhash); err != nil { + // If no user found with name, create the user + if errors.Is(err, sql.ErrNoRows) { + pwdhash_bytes, err := bcrypt.GenerateFromPassword(password, 12) + if err != nil { + http.Error(res, fmt.Sprintf("user: failed to generate password hash (%s)", err), http.StatusInternalServerError) + return + } + pwdhash := string(pwdhash_bytes) + + _, err = h.db.Exec("INSERT INTO users VALUES (?,?)", name, pwdhash) + if err != nil { + http.Error(res, fmt.Sprintf("db: failed to create user (%s)", err), http.StatusInternalServerError) + return + } + } else { + http.Error(res, fmt.Sprintf("db: failed to query row (%s)", err), http.StatusInternalServerError) + return + } + } else if bcrypt.CompareHashAndPassword([]byte(db_pwdhash), password) != nil { + http.Error(res, "User exists, but invalid password", http.StatusForbidden) + return + } + + // JWT generation + token := jwt.New(jwt.SigningMethodES256) + claims := token.Claims.(jwt.MapClaims) + claims["name"] = name + claims["pwdhash"] = db_pwdhash + + token_string, err := token.SignedString(h.jwt_secret) + if err != nil { + http.Error(res, fmt.Sprintf("jwt: failed to generate token (%s)", err), http.StatusInternalServerError) + return + } + + fmt.Fprintf(res, "%s", token_string) +} + +// TODO(midnadimple): implement: +func (h *UserHandler) updateUser(res http.ResponseWriter, req *http.Request) {} +func (h *UserHandler) deleteUser(res http.ResponseWriter, req *http.Request) {} diff --git a/backend/util.go b/backend/util.go new file mode 100644 index 0000000..50bcbf8 --- /dev/null +++ b/backend/util.go @@ -0,0 +1,16 @@ +package main + +import ( + "path" + "strings" +) + +// ShiftPath function taken from https://blog.merovius.de/posts/2017-06-18-how-not-to-use-an-http-router/ +func ShiftPath(p string) (head, tail string) { + p = path.Clean("/" + p) + i := strings.Index(p[1:], "/") + 1 + if i <= 0 { + return p[1:], "/" + } + return p[1:i], p[i:] +}