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
This commit is contained in:
parent
9157bfe8a1
commit
e08adc96ae
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.db
|
||||||
|
.env
|
15
backend/README.md
Normal file
15
backend/README.md
Normal file
|
@ -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
|
|
@ -1,3 +1,10 @@
|
||||||
module github.com/devraza/ambition/backend
|
module github.com/devraza/ambition/backend
|
||||||
|
|
||||||
go 1.20
|
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
|
||||||
|
)
|
||||||
|
|
8
backend/go.sum
Normal file
8
backend/go.sum
Normal file
|
@ -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=
|
|
@ -2,37 +2,39 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
type App struct {
|
||||||
serverAddress = "localhost:7764"
|
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() {
|
func main() {
|
||||||
log.Println("Ambition going strong at", serverAddress)
|
user_handler, err := NewUserHandler()
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", serverAddress)
|
|
||||||
if err != nil {
|
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
|
a := &App{
|
||||||
go handleConnection(conn)
|
UserHandler: user_handler,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleConnection(conn net.Conn) {
|
port := os.Getenv("PORT")
|
||||||
defer conn.Close()
|
if port == "" {
|
||||||
|
port = "7741"
|
||||||
// TODO implement actual server. Waiting on frontend for this
|
}
|
||||||
|
log.Println("Ambition going strong at port 7741")
|
||||||
|
http.ListenAndServe(":"+port, a)
|
||||||
}
|
}
|
||||||
|
|
4
backend/sql/init.sql
Normal file
4
backend/sql/init.sql
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
pwdhash TEXT NOT NULL
|
||||||
|
);
|
126
backend/user.go
Normal file
126
backend/user.go
Normal file
|
@ -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) {}
|
16
backend/util.go
Normal file
16
backend/util.go
Normal file
|
@ -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:]
|
||||||
|
}
|
Reference in a new issue