api.spaceplanner.app

Spaceplanner API
git clone git://jacobedwards.org/api.spaceplanner.app
Log | Files | Refs

commit 225e0dbac33eb452a9357c99d7b85bb59ce01e2f
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date:   Fri, 26 Jul 2024 19:33:03 -0700

Initial commit

Add basic framework to build the rest of the http api on. It currently
allows for user creation, deletion, and authentication.

There are currently no tests as I'm not entirely sure how to best
implement them with the various states that most functions require.

Diffstat:
Acmd/api/auth.go | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/api/env.go | 29+++++++++++++++++++++++++++++
Acmd/api/go.mod | 43+++++++++++++++++++++++++++++++++++++++++++
Acmd/api/go.sum | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/api/main.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/api/respond.go | 32++++++++++++++++++++++++++++++++
Acmd/api/types.go | 6++++++
Acmd/api/users.go | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/backend/env.go | 44++++++++++++++++++++++++++++++++++++++++++++
Ainternal/backend/go.mod | 5+++++
Ainternal/backend/go.sum | 2++
Ainternal/backend/user.go | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 602 insertions(+), 0 deletions(-)

diff --git a/cmd/api/auth.go b/cmd/api/auth.go @@ -0,0 +1,127 @@ +package main + +import ( + "errors" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + jwt "github.com/appleboy/gin-jwt/v2" + + "jacobedwards.org/spaceplanner.app/internal/backend" +) + +var ( + identityKey = "id" +) + +func AuthMiddleware(authMiddleware *jwt.GinJWTMiddleware) gin.HandlerFunc { + return func(context *gin.Context) { + errInit := authMiddleware.MiddlewareInit() + if errInit != nil { + log.Fatal("authMiddleware.MiddlewareInit() Error:" + errInit.Error()) + } + } +} + +func (e *Env) AuthParams(key []byte) (*jwt.GinJWTMiddleware, error) { + if key == nil || len(key) == 0 { + return nil, errors.New("Invalid key") + } + return &jwt.GinJWTMiddleware{ + // TODO: Don't know what Realm is, but should probably be changed + Realm: "test zone", + Key: key, + Timeout: time.Hour, + MaxRefresh: time.Hour, + IdentityKey: identityKey, + PayloadFunc: payloadFunc(), + + IdentityHandler: identityHandler(), + Authenticator: e.authenticator(), + Authorizator: authorizator(), + Unauthorized: unauthorized(), + TokenLookup: "header: Authorization, query: token, cookie: jwt", + // TokenLookup: "query:token", + // TokenLookup: "cookie:token", + TokenHeadName: "Bearer", + TimeFunc: time.Now, + }, nil +} + +func payloadFunc() func(data interface{}) jwt.MapClaims { + return func(data interface{}) jwt.MapClaims { + if v, ok := data.(backend.User); ok { + return jwt.MapClaims{ + identityKey: v.Name, + } + } + return jwt.MapClaims{} + } +} + +func identityHandler() func(c *gin.Context) interface{} { + return func(c *gin.Context) interface{} { + claims := jwt.ExtractClaims(c) + return &backend.User{ + Name: claims[identityKey].(string), + } + } +} + +func (e *Env) authenticator() func(c *gin.Context) (interface{}, error) { + return func(c *gin.Context) (interface{}, error) { + var creds Credentials + if err := c.ShouldBind(&creds); err != nil { + return "", jwt.ErrMissingLoginValues + } + + user, err := e.backend.LoginUser(creds.Username, creds.Password) + if err != nil { + return nil, err + } + return user, nil + } +} + +func authorizator() func(data interface{}, c *gin.Context) bool { + // Is authenticated user allowed to do X? + return func(data interface{}, c *gin.Context) bool { + owner := c.Param("user") + v, ok := data.(*backend.User) + if !ok { + log.Panic("Expected *backend.User") + } + + if v.Name == owner { + return true + } + log.Printf("User %q unauthorized to access resource owned by %q", v.Name, owner) + return false + } +} + +func unauthorized() func(c *gin.Context, code int, message string) { + return func(c *gin.Context, code int, message string) { + RespondError(c, code, "Unauthorized: %s", message); + } +} + +func handleNoRoute() func(c *gin.Context) { + return func(c *gin.Context) { + claims := jwt.ExtractClaims(c) + log.Printf("NoRoute claims: %#v\n", claims) + RespondError(c, http.StatusNotFound, "Endpoint not found") + } +} + +func testHandler(c *gin.Context) { + claims := jwt.ExtractClaims(c) + user, _ := c.Get(identityKey) + c.JSON(200, gin.H{ + "ID": claims[identityKey], + "Username": user, + "text": "Test.", + }) +} diff --git a/cmd/api/env.go b/cmd/api/env.go @@ -0,0 +1,29 @@ +package main + +import ( + "errors" + "database/sql" + + "jacobedwards.org/spaceplanner.app/internal/backend" +) + +type Env struct { + backend *backend.Env +} + +func NewEnv(db *sql.DB) (*Env, error) { + if db == nil { + return nil, errors.New("No database") + } + backend, err := backend.NewEnv(db) + if err != nil { + return nil, err + } + return &Env{ + backend: backend, + }, nil +} + +func (e *Env) Free() { + e.backend.Free() +} diff --git a/cmd/api/go.mod b/cmd/api/go.mod @@ -0,0 +1,43 @@ +module jacobedwards.org/spaceplanner.app/cmd/api + +go 1.22.1 + +replace jacobedwards.org/spaceplanner.app/internal/backend => ../../internal/backend + +require ( + github.com/appleboy/gin-jwt/v2 v2.9.2 + github.com/gin-gonic/gin v1.10.0 + github.com/lib/pq v1.10.9 + jacobedwards.org/spaceplanner.app/internal/backend v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cmd/api/go.sum b/cmd/api/go.sum @@ -0,0 +1,111 @@ +github.com/appleboy/gin-jwt/v2 v2.9.2 h1:GeS3lm9mb9HMmj7+GNjYUtpp3V1DAQ1TkUFa5poiZ7Y= +github.com/appleboy/gin-jwt/v2 v2.9.2/go.mod h1:mxGjKt9Lrx9Xusy1SrnmsCJMZG6UJwmdHN9bN27/QDw= +github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4= +github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/cmd/api/main.go b/cmd/api/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + jwt "github.com/appleboy/gin-jwt/v2" +) + +func main() { + env, err := initEnv("postgres://_postgresql@localhost/test?sslmode=disable") + if err != nil { + log.Fatalf("Unable to initialize environment: %s", err.Error()) + } + defer env.Free() + + authparams, err := env.AuthParams([]byte("Temporary Key")) + if err != nil { + log.Fatal(err.Error()) + } + + auth, err := jwt.New(authparams) + if err != nil { + log.Fatalf("JWT error: %s", err.Error()) + } + + engine := gin.Default() + engine.NoRoute(noRoute) + engine.Use(AuthMiddleware(auth)) + setRoutes(env, engine.Group("/v0"), auth) + engine.Run("localhost:8888") +} + +func initEnv(database string) (*Env, error) { + db, err := sql.Open("postgres", database) + if err != nil { + return nil, err + } + + env, err := NewEnv(db) + if err != nil { + db.Close() + return nil, err + + } + + return env, nil +} + +func setRoutes(env *Env, r *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + r.POST("/tokens", auth.LoginHandler) + r.GET("/tokens", auth.RefreshHandler) + + users := r.Group("/users") + users.GET("/:user", env.GetUser) + users.POST("", env.CreateUser) + + setAuthenticatedRoutes(env, r.Group("", auth.MiddlewareFunc())) +} + +func setAuthenticatedRoutes(env *Env, r *gin.RouterGroup) { + r.DELETE("/users/:user", env.DeleteUser) +} + +func noRoute(c *gin.Context) { + RespondError(c, http.StatusNotFound, "Endpoint does not exist") +} diff --git a/cmd/api/respond.go b/cmd/api/respond.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "errors" + "net/http" + "github.com/gin-gonic/gin" +) + +type Response struct { + // Status of message, 'ok' for success, anything else for an error + Status string `json:"status"` + + // Error message, should be set if status is not 'ok' + Error string `json:"error"` + + // Body of message + Body any `json:"body"` +} + +func Respond(c *gin.Context, status int, body any) { + if (status < 200 || status >= 300) { + c.AbortWithError(http.StatusInternalServerError, + errors.New("Expected successful status")) + } + c.IndentedJSON(status, Response{ Status: "ok", Body: body }) +} + +func RespondError(c *gin.Context, status int, format string, args ...interface{}) { + c.IndentedJSON(status, + Response{ Status: "error", Error: fmt.Sprintf(format, args...) }) +} diff --git a/cmd/api/types.go b/cmd/api/types.go @@ -0,0 +1,6 @@ +package main + +type Credentials struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} diff --git a/cmd/api/users.go b/cmd/api/users.go @@ -0,0 +1,51 @@ +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "jacobedwards.org/spaceplanner.app/internal/backend" +) + +func (e *Env) CreateUser(c *gin.Context) { + var creds Credentials + if err := c.ShouldBind(&creds); err != nil { + RespondError(c, http.StatusUnauthorized, "Credentials not provided") + return + } + + err := e.backend.CreateUser(creds.Username, creds.Password) + if err != nil { + RespondError(c, http.StatusBadRequest, + "Unable to create user: %s", err.Error()) + return + } + Respond(c, http.StatusCreated, backend.User{ Name: creds.Username }) +} + +func (e *Env) DeleteUser(c *gin.Context) { + user := c.Param("user") + err := e.backend.DeleteUser(user) + if err != nil { + RespondError(c, http.StatusBadRequest, "Unable to delete user: %s", err.Error()) + return + } + Respond(c, http.StatusOK, backend.User{ Name: user }) +} + +func (e *Env) GetUser(c *gin.Context) { + name := c.Param("user") + if name == "" { + RespondError(c, http.StatusNotFound, "No username given") + return + } + + user, err := e.backend.GetUser(name) + if err != nil { + RespondError(c, http.StatusOK, "%q: Unable to get user: %s", + name, err.Error()) + return + } + + Respond(c, http.StatusOK, user) +} diff --git a/internal/backend/env.go b/internal/backend/env.go @@ -0,0 +1,44 @@ +package backend + +import ( + "database/sql" + "errors" +) + +type Env struct { + db *sql.DB + stmts map[string]*sql.Stmt +} + +// Creates new backend environemnt with the given database +// (which will be managed by these functions afterwords +func NewEnv(db *sql.DB) (*Env, error) { + if db == nil { + return nil, errors.New("No database") + } + return &Env{ + db: db, + stmts: make(map[string]*sql.Stmt), + }, nil +} + +func (e *Env) CacheStmt(name, sql string) (*sql.Stmt, error) { + stmt, exists := e.stmts[name]; + if exists { + return stmt, nil + } + + stmt, err := e.db.Prepare(sql) + if err != nil { + return nil, err + } + e.stmts[name] = stmt + return stmt, nil +} + +func (e *Env) Free() { + for _, s := range e.stmts { + s.Close() + } + e.db.Close() +} diff --git a/internal/backend/go.mod b/internal/backend/go.mod @@ -0,0 +1,5 @@ +module jacobedwards.org/spaceplanner.app/internal/backend + +go 1.22.1 + +require golang.org/x/crypto v0.25.0 diff --git a/internal/backend/go.sum b/internal/backend/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= diff --git a/internal/backend/user.go b/internal/backend/user.go @@ -0,0 +1,82 @@ +package backend + +import ( + "errors" + "golang.org/x/crypto/bcrypt" +) + +// Database representation of user +type User struct { + Name string `json:"name"` + hash string +} + +func (e *Env) CreateUser(username string, password string) error { + if username == "" { + return errors.New("Empty username") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return err + } + + user := User{ Name: username, hash: string(hash) } + err = e.insertUser(user) + if err != nil { + return err + } + return nil +} + +func (e *Env) DeleteUser(username string) error { + stmt, err := e.CacheStmt("delete_user", "DELETE FROM users WHERE users.name = $1") + if err != nil { + return err + } + res, err := stmt.Exec(username) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n == 1 { + return nil + } + return errors.New("No user with that name") +} + +func (e *Env) GetUser(username string) (User, error) { + var user User + + if username == "" { + return User{}, errors.New("Empty username") + } + stmt, err := e.CacheStmt("get_user", "SELECT * FROM users WHERE users.name = $1") + if err != nil { + return User{}, err + } + row := stmt.QueryRow(username) + err = row.Scan(&user.Name, &user.hash) + return user, err +} + +func (e *Env) LoginUser(username string, password string) (User, error) { + user, err := e.GetUser(username) + if err != nil { + return User{}, err; + } + + err = bcrypt.CompareHashAndPassword([]byte(user.hash), []byte(password)) + if err != nil { + return User{}, err + } + return user, nil; +} + +func (e *Env) insertUser(user User) error { + stmt, err := e.CacheStmt("insert_user", "INSERT INTO users VALUES ($1, $2)") + if err != nil { + return err + } + _, err = stmt.Exec(user.Name, user.hash) + return err +}