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:
Abdulmujeeb Raji 2023-07-26 21:16:25 +01:00
parent 9157bfe8a1
commit e08adc96ae
8 changed files with 203 additions and 23 deletions

2
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.db
.env

15
backend/README.md Normal file
View 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

View file

@ -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
View 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=

View file

@ -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
View 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
View 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
View 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:]
}