From 642cf510370d79143c647c2a3b8ffe0f15c0906e Mon Sep 17 00:00:00 2001 From: ruzv Date: Sun, 20 Aug 2023 16:02:55 +0300 Subject: [PATCH 1/8] auth: Implement auth --- .gitignore | 2 +- config/config.go | 9 +++ go.mod | 7 +- go.sum | 24 ++++--- handler/authhttp/authhttp.go | 127 +++++++++++++++++++++++++++++++++ handler/router/router.go | 3 + scripts/gen-paswd-hash/main.go | 38 ++++++++++ 7 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 handler/authhttp/authhttp.go create mode 100644 scripts/gen-paswd-hash/main.go diff --git a/.gitignore b/.gitignore index 9b0ed53..0831ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ logs.log content test-graph local -test-config.json +test-config.yaml diff --git a/config/config.go b/config/config.go index e03ef79..2a4d223 100644 --- a/config/config.go +++ b/config/config.go @@ -22,6 +22,7 @@ type GraphConfig struct { Name pathutil.NodePath `yaml:"name" validate:"nonzero"` Path string `yaml:"path" validate:"nonzero"` Sync *SyncConfig `yaml:"sync" validate:"nonnil"` + Auth *AuthConfig `yaml:"auth" validate:"nonnil"` } // SyncConfig defines configuration params for periodically syncing graph to a @@ -32,6 +33,14 @@ type SyncConfig struct { KeyPassword string `yaml:"keyPassword"` } +// AuthConfig defines configuration params for authentication. +type AuthConfig struct { + Username string `yaml:"username" validate:"nonzero"` + PasswordHash string `yaml:"passwordHash" validate:"nonzero"` + TokenExpiration time.Duration `yaml:"tokenExpiration" validate:"nonzero"` + Secret string `yaml:"secret" validate:"nonzero"` +} + // Load loads the configuration from a file. func Load(path string) (*Config, error) { f, err := os.Open(path) diff --git a/go.mod b/go.mod index f3e4942..646d748 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,15 @@ go 1.21 require ( github.com/go-git/go-git/v5 v5.8.1 github.com/gofrs/uuid v4.2.0+incompatible + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/gomarkdown/markdown v0.0.0-20220731190611-dcdaee8e7a53 github.com/google/go-cmp v0.5.9 github.com/gorilla/mux v1.8.0 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pkg/errors v0.9.1 + github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 + golang.org/x/crypto v0.12.0 gopkg.in/validator.v2 v2.0.1 gopkg.in/yaml.v2 v2.4.0 ) @@ -25,6 +28,7 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.4.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect @@ -32,10 +36,9 @@ require ( github.com/skeema/knownhosts v1.2.0 // indirect github.com/stretchr/testify v1.8.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.11.0 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.12.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/sys v0.11.0 // indirect golang.org/x/tools v0.6.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index a434824..f3622f2 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,7 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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= @@ -34,6 +35,8 @@ github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+ github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/gomarkdown/markdown v0.0.0-20220731190611-dcdaee8e7a53 h1:JguE3sS3yLzLiCTCnsmzVFuTvTMDJALbzCgurwY5G/0= @@ -42,6 +45,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= @@ -67,11 +72,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM= github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -87,8 +95,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -118,15 +126,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -134,8 +142,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/handler/authhttp/authhttp.go b/handler/authhttp/authhttp.go new file mode 100644 index 0000000..915c6bc --- /dev/null +++ b/handler/authhttp/authhttp.go @@ -0,0 +1,127 @@ +package auth + +import ( + "net/http" + "strings" + "time" + + "rat/config" + "rat/handler/shared" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" + "github.com/op/go-logging" + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" +) + +var log = logging.MustGetLogger("authhttp") + +type handler struct { + conf *config.AuthConfig +} + +// RegisterRoutes registers view routes on given router. +func RegisterRoutes(router *mux.Router, conf *config.AuthConfig) { + h := &handler{ + conf: conf, + } + + authRouter := router.PathPrefix("/auth"). + Subrouter(). + StrictSlash(true) + + authRouter.HandleFunc("/", shared.Wrap(h.auth)). + Methods(http.MethodGet) +} + +func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { + username, password, ok := r.BasicAuth() + if !ok { + shared.WriteError( + w, + http.StatusBadRequest, + "failed to parse Authorization header as basic auth", + ) + + return errors.New("failed to parse Authorization header as basic auth") + } + + if h.conf.Username != username { + shared.WriteError( + w, + http.StatusUnauthorized, + "failed to authenticate user", + ) + + return errors.New("failed to authenticate user, user name mismatch") + } + + err := bcrypt.CompareHashAndPassword( + []byte(h.conf.PasswordHash), []byte(password), + ) + if err != nil { + shared.WriteError( + w, + http.StatusUnauthorized, + "failed to authenticate user", + ) + + return errors.New("failed to authenticate user, password mismatch") + } + + token, err := jwt.NewWithClaims( + jwt.SigningMethodHS512, + jwt.MapClaims{ + "expires": time.Now().Add(h.conf.TokenExpiration).Unix(), + "role": "owner", + }, + ).SignedString([]byte(h.conf.Secret)) + if err != nil { + shared.WriteError( + w, http.StatusInternalServerError, "failed to sign token", + ) + + return errors.Wrap(err, "failed to sign token") + } + + err = shared.WriteResponse( + w, + http.StatusOK, + struct { + Token string `json:"token"` + }{ + Token: token, + }, + ) + if err != nil { + return errors.Wrap(err, "failed to write response") + } + + return nil +} + +func AuthMW() mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headerParts := strings.Fields(r.Header.Get("Authorization")) + if len(headerParts) != 2 || + strings.ToLower(headerParts[0]) != "bearer" { + shared.WriteError( + w, http.StatusBadRequest, "invalid Authorization header", + ) + + return + } + + token, err := jwt.Parse( + headerParts[1], + func(token *jwt.Token) (interface{}, error) { + // return secret + } + jwt.WithValidMethods([]string{"HS512"}), + ) + + }) + } +} diff --git a/handler/router/router.go b/handler/router/router.go index 84f8b77..f684935 100644 --- a/handler/router/router.go +++ b/handler/router/router.go @@ -6,6 +6,7 @@ import ( "time" "rat/config" + auth "rat/handler/authhttp" "rat/handler/graphhttp" "rat/handler/shared" "rat/handler/statichttp" @@ -39,6 +40,8 @@ func New( }, ) + auth.RegisterRoutes(router, conf.Graph.Auth) + templateFS, err := fs.Sub(embeds, "render-templates") if err != nil { return nil, errors.Wrap(err, "failed to get render-templates sub fs") diff --git a/scripts/gen-paswd-hash/main.go b/scripts/gen-paswd-hash/main.go new file mode 100644 index 0000000..5a907a8 --- /dev/null +++ b/scripts/gen-paswd-hash/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + "golang.org/x/crypto/bcrypt" +) + +func main() { + var ( + password string + cost int + ) + cli := &cobra.Command{ + Use: "gen-paswd-hash", + Short: "generate a password hash", + Long: "generate a password hash to be used for graph owners auth", + Run: func(cmd *cobra.Command, args []string) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) + if err != nil { + panic(err) + } + + fmt.Println(hash) + fmt.Println(string(hash)) + }, + } + + cli.Flags(). + StringVarP(&password, "password", "p", "", "password to hash") + cli.MarkFlagRequired("password") + + cli.Flags(). + IntVarP(&cost, "cost", "c", bcrypt.MinCost, "cost of bcrypt hash function") + + cli.Execute() +} From 8960a7a798710366a47e1e6b861e4ba6ea1e39f5 Mon Sep 17 00:00:00 2001 From: ruzv Date: Mon, 2 Oct 2023 17:40:08 +0300 Subject: [PATCH 2/8] auth: Merge latest master --- handler/authhttp/authhttp.go | 30 ++++++++++++++++-------------- handler/router/router.go | 16 +++------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/handler/authhttp/authhttp.go b/handler/authhttp/authhttp.go index 915c6bc..01fba95 100644 --- a/handler/authhttp/authhttp.go +++ b/handler/authhttp/authhttp.go @@ -5,14 +5,14 @@ import ( "strings" "time" - "rat/config" - "rat/handler/shared" - "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/op/go-logging" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" + "rat/config" + "rat/handler/httputil" + "rat/logr" ) var log = logging.MustGetLogger("authhttp") @@ -22,7 +22,9 @@ type handler struct { } // RegisterRoutes registers view routes on given router. -func RegisterRoutes(router *mux.Router, conf *config.AuthConfig) { +func RegisterRoutes( + router *mux.Router, log *logr.LogR, conf *config.AuthConfig, +) { h := &handler{ conf: conf, } @@ -31,14 +33,14 @@ func RegisterRoutes(router *mux.Router, conf *config.AuthConfig) { Subrouter(). StrictSlash(true) - authRouter.HandleFunc("/", shared.Wrap(h.auth)). + authRouter.HandleFunc("/", httputil.Wrap(log, h.auth)). Methods(http.MethodGet) } func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { username, password, ok := r.BasicAuth() if !ok { - shared.WriteError( + httputil.WriteError( w, http.StatusBadRequest, "failed to parse Authorization header as basic auth", @@ -48,7 +50,7 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { } if h.conf.Username != username { - shared.WriteError( + httputil.WriteError( w, http.StatusUnauthorized, "failed to authenticate user", @@ -61,7 +63,7 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { []byte(h.conf.PasswordHash), []byte(password), ) if err != nil { - shared.WriteError( + httputil.WriteError( w, http.StatusUnauthorized, "failed to authenticate user", @@ -78,14 +80,14 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { }, ).SignedString([]byte(h.conf.Secret)) if err != nil { - shared.WriteError( + httputil.WriteError( w, http.StatusInternalServerError, "failed to sign token", ) return errors.Wrap(err, "failed to sign token") } - err = shared.WriteResponse( + err = httputil.WriteResponse( w, http.StatusOK, struct { @@ -107,7 +109,7 @@ func AuthMW() mux.MiddlewareFunc { headerParts := strings.Fields(r.Header.Get("Authorization")) if len(headerParts) != 2 || strings.ToLower(headerParts[0]) != "bearer" { - shared.WriteError( + httputil.WriteError( w, http.StatusBadRequest, "invalid Authorization header", ) @@ -117,11 +119,11 @@ func AuthMW() mux.MiddlewareFunc { token, err := jwt.Parse( headerParts[1], func(token *jwt.Token) (interface{}, error) { - // return secret - } + return nil, nil + // return secret + }, jwt.WithValidMethods([]string{"HS512"}), ) - }) } } diff --git a/handler/router/router.go b/handler/router/router.go index f88f42c..17bfc89 100644 --- a/handler/router/router.go +++ b/handler/router/router.go @@ -5,15 +5,10 @@ import ( "net/http" "time" - "rat/config" - auth "rat/handler/authhttp" - "rat/handler/graphhttp" - "rat/handler/shared" - "rat/handler/statichttp" - "rat/handler/viewhttp" "github.com/gorilla/mux" "github.com/pkg/errors" "rat/graph/services" + auth "rat/handler/authhttp" "rat/handler/graphhttp" "rat/handler/httputil" "rat/handler/viewhttp" @@ -42,14 +37,9 @@ func NewRouter( }, ) - auth.RegisterRoutes(router, conf.Graph.Auth) - - templateFS, err := fs.Sub(embeds, "render-templates") - if err != nil { - return nil, errors.Wrap(err, "failed to get render-templates sub fs") - } + auth.RegisterRoutes(router, log, conf.Graph.Auth) - router.Use(GetAccessLoggerMW(log, false)) + router.Use(GetAccessLoggerMW(log, false)) err := graphhttp.RegisterRoutes(router, log, gs) if err != nil { From 1936c26001ac966d2248737129f2bb86864376ae Mon Sep 17 00:00:00 2001 From: ruzv Date: Thu, 28 Dec 2023 14:31:31 +0200 Subject: [PATCH 3/8] rat-0006: Initial implementation for auth in server and web --- config/config.go | 67 ------ src/go.mod | 4 +- src/go.sum | 2 + src/graph/services/auth/auth.go | 89 +++++++ src/graph/services/auth/token.go | 42 ++++ src/graph/services/services.go | 4 + src/graph/util/util.go | 12 + src/handler/authhttp/authhttp.go | 226 ++++++++++++------ src/handler/graphhttp/nodeshttp/nodeshttp.go | 2 +- src/handler/router/router.go | 29 ++- src/scripts/gen-paswd-hash/main.go | 28 ++- src/web/src/api/auth.ts | 11 + src/web/src/api/node.ts | 14 +- src/web/src/components/atoms.tsx | 17 ++ .../src/components/buttons/buttons.module.css | 38 --- src/web/src/components/console.tsx | 25 +- src/web/src/components/parts.module.css | 24 -- src/web/src/components/parts.tsx | 59 ++--- src/web/src/components/util.module.css | 35 +++ src/web/src/components/util.tsx | 37 +++ src/web/src/index.tsx | 24 +- src/web/src/pages/landing.tsx | 15 ++ src/web/src/pages/signin.tsx | 97 ++++++++ src/web/src/pages/view.tsx | 81 +++++++ src/web/src/types/session.tsx | 3 + src/web/src/view.tsx | 48 ---- 26 files changed, 714 insertions(+), 319 deletions(-) delete mode 100644 config/config.go create mode 100644 src/graph/services/auth/auth.go create mode 100644 src/graph/services/auth/token.go create mode 100644 src/web/src/api/auth.ts create mode 100644 src/web/src/components/util.module.css create mode 100644 src/web/src/pages/landing.tsx create mode 100644 src/web/src/pages/signin.tsx create mode 100644 src/web/src/pages/view.tsx create mode 100644 src/web/src/types/session.tsx delete mode 100644 src/web/src/view.tsx diff --git a/config/config.go b/config/config.go deleted file mode 100644 index c1ef302..0000000 --- a/config/config.go +++ /dev/null @@ -1,67 +0,0 @@ -package config - -import ( - "os" - "time" - - "github.com/pkg/errors" - "gopkg.in/validator.v2" - "gopkg.in/yaml.v2" - pathutil "rat/graph/util/path" - "rat/logr" -) - -// Config is the configuration for the application. -type Config struct { - Port int `yaml:"port" validate:"min=1"` - Graph *GraphConfig `yaml:"graph" validate:"nonnil"` - LogLevel logr.LogLevel `yaml:"logLevel"` -} - -// GraphConfig is the configuration for the graph. -type GraphConfig struct { - Name pathutil.NodePath `yaml:"name" validate:"nonzero"` - Path string `yaml:"path" validate:"nonzero"` - Sync *SyncConfig `yaml:"sync" validate:"nonnil"` - Auth *AuthConfig `yaml:"auth" validate:"nonnil"` -} - -// SyncConfig defines configuration params for periodically syncing graph to a -// git repository. -type SyncConfig struct { - Interval time.Duration `yaml:"interval" validate:"nonzero"` - KeyPath string `yaml:"keyPath" validate:"nonzero"` - KeyPassword string `yaml:"keyPassword"` -} - -// AuthConfig defines configuration params for authentication. -type AuthConfig struct { - Username string `yaml:"username" validate:"nonzero"` - PasswordHash string `yaml:"passwordHash" validate:"nonzero"` - TokenExpiration time.Duration `yaml:"tokenExpiration" validate:"nonzero"` - Secret string `yaml:"secret" validate:"nonzero"` -} - -// Load loads the configuration from a file. -func Load(path string) (*Config, error) { - f, err := os.Open(path) - if err != nil { - return nil, errors.Wrap(err, "failed to open file") - } - - defer f.Close() - - c := &Config{} - - err = yaml.NewDecoder(f).Decode(c) - if err != nil { - return nil, errors.Wrap(err, "failed to decode config") - } - - err = validator.Validate(c) - if err != nil { - return nil, errors.Wrap(err, "failed to validate config") - } - - return c, nil -} diff --git a/src/go.mod b/src/go.mod index e4818eb..2f803f9 100644 --- a/src/go.mod +++ b/src/go.mod @@ -7,11 +7,11 @@ require ( github.com/fatih/color v1.15.0 github.com/go-git/go-git/v5 v5.8.1 github.com/gofrs/uuid v4.2.0+incompatible + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/gomarkdown/markdown v0.0.0-20230922105210-14b16010c2ee github.com/google/go-cmp v0.5.9 - github.com/golang-jwt/jwt/v5 v5.0.0 github.com/gorilla/mux v1.8.0 - github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 + github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/errors v0.9.1 github.com/sahilm/fuzzy v0.1.0 github.com/spf13/cobra v1.7.0 diff --git a/src/go.sum b/src/go.sum index f54f9af..4c28011 100644 --- a/src/go.sum +++ b/src/go.sum @@ -116,6 +116,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= diff --git a/src/graph/services/auth/auth.go b/src/graph/services/auth/auth.go new file mode 100644 index 0000000..e1df90d --- /dev/null +++ b/src/graph/services/auth/auth.go @@ -0,0 +1,89 @@ +package auth + +import ( + "time" + + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +//nolint:gochecknoglobals +var ( + // Owner role gives a user unlimited access to the graph. + Owner Role = "owner" + // Member role gives a user full access to graph nodes marked with + // access: member and its sub-nodes. + Member Role = "member" + // Viewer role gives a user read access to graph nodes marked with + // access: viewer and its sub-nodes. + Viewer Role = "viewer" + // Visitor role gives read access unauthenticated users to graph nodes + // marked with access: visitor and its sub-nodes. + Visitor Role = "visitor" +) + +var _ yaml.Unmarshaler = (*Role)(nil) + +// Role defines a user role. +type Role string + +// Credentials defines users username and password. +type Credentials struct { + Username string `yaml:"username" validate:"nonzero"` + Password string `yaml:"password" validate:"nonzero"` +} + +// User defines a user with credentials and role. +type User struct { + Credentials `yaml:",inline"` + Role Role `yaml:"role" validate:"nonzero"` +} + +// TokenConfig defines configuration params for JWT token generation. +type TokenConfig struct { + Secret string `yaml:"secret" validate:"nonzero"` + Expiration time.Duration `yaml:"expiration" validate:"nonzero"` +} + +// Config defines configuration params for authentication. +type Config struct { + Owner *Credentials `yaml:"owner" validate:"nonzero"` + Users []*User `yaml:"users"` + Token *TokenConfig `yaml:"token" validate:"nonzero"` +} + +// AllUsers returns all users, with roles and credentials, including the owner +// user. +func (c *Config) AllUsers() []*User { + return append( + []*User{ + { + Credentials: *c.Owner, + Role: Owner, + }, + }, + c.Users..., + ) +} + +// UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid +// values. +func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + + err := unmarshal(&raw) + if err != nil { + return errors.Wrap(err, "failed to unmarshal role") + } + + role := Role(raw) + + switch role { + case Owner, Member, Viewer: + *r = role + + return nil + default: + return errors.Errorf("invalid role: %s", raw) + } +} diff --git a/src/graph/services/auth/token.go b/src/graph/services/auth/token.go new file mode 100644 index 0000000..64dc39c --- /dev/null +++ b/src/graph/services/auth/token.go @@ -0,0 +1,42 @@ +package auth + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +// Token defines data stored in JWT token. +type Token struct { + Username string `mapstructure:"username"` + Expires int64 `mapstructure:"expires"` + Role Role `mapstructure:"role"` +} + +// FromMapClaims converts jwt.MapClaims to token claims. +func FromMapClaims(mc jwt.MapClaims) (*Token, error) { + t := &Token{} + + err := mapstructure.Decode(mc, t) + if err != nil { + return nil, errors.Wrap(err, "failed to decode token claims") + } + + return t, nil +} + +// ToMapClaims converts token claims to jwt.MapClaims. +func (t Token) ToMapClaims() jwt.MapClaims { + return jwt.MapClaims{ + "username": t.Username, + "expires": t.Expires, + "role": t.Role, + } +} + +// Expired returns true if the token is expired. +func (t *Token) Expired() bool { + return time.Unix(t.Expires, 0).Before(time.Now()) +} diff --git a/src/graph/services/services.go b/src/graph/services/services.go index eb8280c..d7c3e20 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -7,6 +7,7 @@ import ( "rat/graph" "rat/graph/index" "rat/graph/provider" + "rat/graph/services/auth" "rat/graph/services/urlresolve" "rat/graph/sync" "rat/logr" @@ -17,6 +18,7 @@ type Config struct { Provider *provider.Config `yaml:"provider"` URLResolver *urlresolve.Config `yaml:"urlResolver"` Sync *sync.Config `yaml:"sync"` + Auth *auth.Config `yaml:"auth"` } // GraphServices contains service components of a graph. @@ -25,6 +27,7 @@ type GraphServices struct { Syncer *sync.Syncer Index *index.GraphIndex URLResolver *urlresolve.Resolver + Auth *auth.Config log *logr.LogR } @@ -36,6 +39,7 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { err error gs = &GraphServices{ URLResolver: urlresolve.NewResolver(c.URLResolver, log), + Auth: c.Auth, log: log, } ) diff --git a/src/graph/util/util.go b/src/graph/util/util.go index 3dae55e..82ebf56 100644 --- a/src/graph/util/util.go +++ b/src/graph/util/util.go @@ -23,6 +23,7 @@ func Map[T, R any](s []T, f func(T) R) []R { // Values returns all values of a map. func Values[K comparable, V any](m map[K]V) []V { r := make([]V, 0, len(m)) + for _, v := range m { r = append(r, v) } @@ -30,6 +31,17 @@ func Values[K comparable, V any](m map[K]V) []V { return r } +// ObjectMap returns a map of objects by the given key. +func ObjectMap[K comparable, V any](objects []V, key func(V) K) map[K]V { + r := make(map[K]V, len(objects)) + + for _, o := range objects { + r[key(o)] = o + } + + return r +} + // Filter creates a new slice of entries of s that valid function return's // true to. func Filter[T any](s []T, valid func(T) bool) []T { diff --git a/src/handler/authhttp/authhttp.go b/src/handler/authhttp/authhttp.go index 01fba95..d064edd 100644 --- a/src/handler/authhttp/authhttp.go +++ b/src/handler/authhttp/authhttp.go @@ -7,84 +7,103 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" - "github.com/op/go-logging" "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" - "rat/config" + "rat/graph/services/auth" + "rat/graph/util" "rat/handler/httputil" "rat/logr" ) -var log = logging.MustGetLogger("authhttp") - type handler struct { - conf *config.AuthConfig + log *logr.LogR + users map[string]*auth.User + tokenConfig *auth.TokenConfig } -// RegisterRoutes registers view routes on given router. +// RegisterRoutes registers auth routes on given router. func RegisterRoutes( - router *mux.Router, log *logr.LogR, conf *config.AuthConfig, -) { + router *mux.Router, + log *logr.LogR, + users []*auth.User, + tokenConfig *auth.TokenConfig, +) (mux.MiddlewareFunc, error) { + log = log.Prefix("auth") + h := &handler{ - conf: conf, + log: log, + users: util.ObjectMap( + users, + func(u *auth.User) string { return u.Username }, + ), + tokenConfig: tokenConfig, } - authRouter := router.PathPrefix("/auth"). - Subrouter(). - StrictSlash(true) - - authRouter.HandleFunc("/", httputil.Wrap(log, h.auth)). - Methods(http.MethodGet) + authRouter := router.PathPrefix("/auth").Subrouter() + + authRouter.HandleFunc( + "", + httputil.Wrap( + httputil.WrapOptions( + h.auth, + []string{http.MethodPost}, + []string{"Content-Type"}, + ), + log, + "auth", + ), + ).Methods(http.MethodPost, http.MethodOptions) + + return h.authMW, nil } func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { - username, password, ok := r.BasicAuth() - if !ok { - httputil.WriteError( - w, - http.StatusBadRequest, - "failed to parse Authorization header as basic auth", + body, err := httputil.Body[struct { + Username string `json:"username"` + Password string `json:"password"` + }](w, r) + if err != nil { + return httputil.Error( + http.StatusBadRequest, errors.Wrap(err, "failed to parse body"), ) - - return errors.New("failed to parse Authorization header as basic auth") } - if h.conf.Username != username { - httputil.WriteError( - w, - http.StatusUnauthorized, - "failed to authenticate user", + user, ok := h.users[body.Username] + if !ok { + return httputil.Error( + http.StatusBadRequest, + errors.New("failed to authenticate user"), ) - - return errors.New("failed to authenticate user, user name mismatch") } - err := bcrypt.CompareHashAndPassword( - []byte(h.conf.PasswordHash), []byte(password), + err = bcrypt.CompareHashAndPassword( + []byte(user.Password), + []byte(body.Password), ) if err != nil { - httputil.WriteError( - w, - http.StatusUnauthorized, - "failed to authenticate user", + h.log.Warnf( + "failed to authenticate user - %q: %s", user.Username, err.Error(), ) - return errors.New("failed to authenticate user, password mismatch") + return httputil.Error( + http.StatusBadRequest, + errors.New("failed to authenticate user"), + ) } token, err := jwt.NewWithClaims( jwt.SigningMethodHS512, - jwt.MapClaims{ - "expires": time.Now().Add(h.conf.TokenExpiration).Unix(), - "role": "owner", - }, - ).SignedString([]byte(h.conf.Secret)) + auth.Token{ + Username: user.Username, + Expires: time.Now().Add(h.tokenConfig.Expiration).Unix(), + Role: user.Role, + }.ToMapClaims(), + ).SignedString([]byte(h.tokenConfig.Secret)) if err != nil { - httputil.WriteError( - w, http.StatusInternalServerError, "failed to sign token", + return httputil.Error( + http.StatusBadRequest, + errors.Wrap(err, "failed to sign token"), ) - - return errors.Wrap(err, "failed to sign token") } err = httputil.WriteResponse( @@ -97,33 +116,102 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { }, ) if err != nil { - return errors.Wrap(err, "failed to write response") + return httputil.Error( + http.StatusBadRequest, + errors.Wrap(err, "failed to write response"), + ) } return nil } -func AuthMW() mux.MiddlewareFunc { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - headerParts := strings.Fields(r.Header.Get("Authorization")) - if len(headerParts) != 2 || - strings.ToLower(headerParts[0]) != "bearer" { - httputil.WriteError( - w, http.StatusBadRequest, "invalid Authorization header", - ) - - return - } - - token, err := jwt.Parse( - headerParts[1], - func(token *jwt.Token) (interface{}, error) { - return nil, nil - // return secret - }, - jwt.WithValidMethods([]string{"HS512"}), +func (h *handler) authMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + + return + } + + headerParts := strings.Fields(r.Header.Get("Authorization")) + + if len(headerParts) != 2 || + strings.EqualFold("Bearer", headerParts[0]) { + h.log.Debugf( + "malformed authorization header: %q", + r.Header.Get("Authorization"), ) - }) - } + + httputil.WriteError( + w, http.StatusBadRequest, "invalid Authorization header", + ) + + return + } + + token, err := jwt.Parse( + headerParts[1], + func(token *jwt.Token) (any, error) { + return []byte(h.tokenConfig.Secret), nil + }, + jwt.WithValidMethods([]string{"HS512"}), + ) + if err != nil { + h.log.Debugf("failed to parse token: %s", err.Error()) + + httputil.WriteError( + w, http.StatusBadRequest, "invalid authorization token", + ) + + return + } + + if !token.Valid { + h.log.Debugf("token not valid") + + httputil.WriteError( + w, http.StatusBadRequest, "invalid authorization token", + ) + + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + h.log.Debugf("failed to parse claims") + + httputil.WriteError( + w, http.StatusBadRequest, "invalid authorization token", + ) + + return + } + + tc, err := auth.FromMapClaims(claims) + if err != nil { + h.log.Debugf("failed to parse token claims") + + httputil.WriteError( + w, http.StatusBadRequest, "invalid authorization token", + ) + + return + } + + if tc.Expired() { + h.log.Debugf( + "token expired, expires - %d, now - %d", + tc.Expires, + time.Now().Unix(), + ) + + httputil.WriteError( + w, http.StatusUnauthorized, "token expired", + ) + + return + } + + next.ServeHTTP(w, r) + }) } diff --git a/src/handler/graphhttp/nodeshttp/nodeshttp.go b/src/handler/graphhttp/nodeshttp/nodeshttp.go index 8349a45..bb803e8 100644 --- a/src/handler/graphhttp/nodeshttp/nodeshttp.go +++ b/src/handler/graphhttp/nodeshttp/nodeshttp.go @@ -53,7 +53,7 @@ func RegisterRoutes( return nil }, []string{http.MethodGet, http.MethodPost, http.MethodDelete}, - []string{"Content-Type"}, + []string{"Content-Type", "Authorization"}, ), log, "read"), ).Methods(http.MethodOptions) diff --git a/src/handler/router/router.go b/src/handler/router/router.go index 66519b7..95b4c1b 100644 --- a/src/handler/router/router.go +++ b/src/handler/router/router.go @@ -53,23 +53,40 @@ func NewRouter( }, ) - auth.RegisterRoutes(router, log, conf.Graph.Auth) - router.Use(GetAccessLoggerMW(log, false)) - err := graphhttp.RegisterRoutes(router, log, gs) + exposedRouter := router.PathPrefix("").Subrouter() + protectedRouter := router.PathPrefix("").Subrouter() + + if gs.Auth != nil { + log.Infof("registering auth routes") + + mw, err := auth.RegisterRoutes( + exposedRouter, + log, + gs.Auth.AllUsers(), + gs.Auth.Token, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to register auth routes") + } + + protectedRouter.Use(mw) + } + + err := graphhttp.RegisterRoutes(protectedRouter, log, gs) if err != nil { return nil, errors.Wrap(err, "failed to register graph routes") } - err = viewhttp.RegisterRoutes(router, log, webStaticContent) + err = viewhttp.RegisterRoutes(exposedRouter, log, webStaticContent) if err != nil { return nil, errors.Wrap(err, "failed to register web routes") } - router.HandleFunc("/test", + protectedRouter.HandleFunc("/test", func(w http.ResponseWriter, _ *http.Request) { - httputil.WriteError(w, http.StatusOK, "not found") + httputil.WriteError(w, http.StatusOK, "r we testing, huh?") }, ) diff --git a/src/scripts/gen-paswd-hash/main.go b/src/scripts/gen-paswd-hash/main.go index 5a907a8..fbed532 100644 --- a/src/scripts/gen-paswd-hash/main.go +++ b/src/scripts/gen-paswd-hash/main.go @@ -1,17 +1,24 @@ package main import ( - "fmt" + "os" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" + "rat/logr" ) func main() { var ( password string cost int + log = logr.NewLogR( + os.Stdout, + "rat-gen-paswd-hash", + logr.LogLevelDebug, + ) ) + cli := &cobra.Command{ Use: "gen-paswd-hash", Short: "generate a password hash", @@ -22,17 +29,24 @@ func main() { panic(err) } - fmt.Println(hash) - fmt.Println(string(hash)) + log.Infof("password hash:\n%s", string(hash)) }, } cli.Flags(). StringVarP(&password, "password", "p", "", "password to hash") - cli.MarkFlagRequired("password") - cli.Flags(). - IntVarP(&cost, "cost", "c", bcrypt.MinCost, "cost of bcrypt hash function") + err := cli.MarkFlagRequired("password") + if err != nil { + panic(err) + } - cli.Execute() + cli.Flags().IntVarP( + &cost, "cost", "c", bcrypt.MinCost, "cost of bcrypt hash function", + ) + + err = cli.Execute() + if err != nil { + panic(err) + } } diff --git a/src/web/src/api/auth.ts b/src/web/src/api/auth.ts new file mode 100644 index 0000000..2336706 --- /dev/null +++ b/src/web/src/api/auth.ts @@ -0,0 +1,11 @@ +import { ratAPIBaseURL } from "./api"; +import axios from "axios"; + +export async function signIn(username: string, password: string) { + let resp = await axios.post<{ token: string }>(`${ratAPIBaseURL()}/auth`, { + username, + password, + }); + + return resp.data.token; +} diff --git a/src/web/src/api/node.ts b/src/web/src/api/node.ts index 66ad4a9..4768e79 100644 --- a/src/web/src/api/node.ts +++ b/src/web/src/api/node.ts @@ -3,15 +3,19 @@ import { Node } from "../types/node"; import axios from "axios"; export async function create(path: string, name: string) { - const url = `${ratAPIBaseURL()}/graph/node/${path}`; - - let resp = await axios.post(url, { name }); + let resp = await axios.post(`${ratAPIBaseURL()}/graph/node/${path}`, { + name, + }); return resp.data; } -export async function read(path: string) { - let resp = await axios.get(`${ratAPIBaseURL()}/graph/node/${path}`); +export async function read(token: string, path: string) { + let resp = await axios.get(`${ratAPIBaseURL()}/graph/node/${path}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); return resp.data; } diff --git a/src/web/src/components/atoms.tsx b/src/web/src/components/atoms.tsx index db9d0df..7318785 100644 --- a/src/web/src/components/atoms.tsx +++ b/src/web/src/components/atoms.tsx @@ -1,5 +1,22 @@ import { atom } from "jotai"; import { Node, NodeAstPart } from "../types/node"; +import { Session } from "../types/session"; + +export const sessionAtom = atom( + (() => { + let localAPIToken = localStorage.getItem("rat_api_token"); + + if (!localAPIToken || localAPIToken === "") { + return; + } + + //TODO: parse jwt, check expiration, populate other fields. + + return { + token: localAPIToken, + }; + })(), +); export const nodeAtom = atom(undefined); diff --git a/src/web/src/components/buttons/buttons.module.css b/src/web/src/components/buttons/buttons.module.css index 90aecdb..8b73cc7 100644 --- a/src/web/src/components/buttons/buttons.module.css +++ b/src/web/src/components/buttons/buttons.module.css @@ -39,41 +39,3 @@ gap: 6px; } -.clickable { - cursor: pointer; -} - -.clickable:hover { - border: 3px solid #841a84; -} - -.clickable:active { - border: 3px solid #ffffff; -} - -/* .consoleButton { */ -/* overflow-wrap: anywhere; */ -/* display: inline-block; */ -/* border: 3px solid #474646; */ -/* background-color: #474646; */ -/* padding-left: 5px; */ -/* padding-right: 5px; */ -/* border-radius: 5px; */ -/* font-size: 130%; */ -/* margin-right: 6px; */ -/* margin-bottom: 6px; */ -/* box-sizing: border-box; */ -/* } */ - -/* .deleteButton { */ -/* position: absolute; */ -/* z-index: 1; */ -/* width: 40px; */ -/* height: 40px; */ -/* background-color: #474646; */ -/* top: 30px; */ -/* right: 30px; */ -/* border-radius: 5px; */ -/* border: 3px solid #474646; */ -/* padding: 2px; */ -/* } */ diff --git a/src/web/src/components/console.tsx b/src/web/src/components/console.tsx index 994d113..0ca0cde 100644 --- a/src/web/src/components/console.tsx +++ b/src/web/src/components/console.tsx @@ -1,4 +1,4 @@ -import { modalOpenAtom, nodePathAtom, childNodesAtom } from "./atoms"; +import { nodeAtom, modalOpenAtom, nodePathAtom, childNodesAtom } from "./atoms"; import { TextButton, IconButton, ButtonRow } from "./buttons/buttons"; import { ConfirmModal, ContentModal } from "./modals"; import { Spacer } from "./util"; @@ -17,15 +17,20 @@ import { useNavigate } from "react-router-dom"; import { useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -export function Console({ id }: { id: string }) { +export function Console() { const navigate = useNavigate(); - const nodePath = useAtomValue(nodePathAtom); - const isRoot = nodePath === ""; + const node = useAtomValue(nodeAtom); + + if (!node) { + return <>; + } + + const isRoot = node.path === ""; let pathParts: string[] = []; - if (nodePath) { - pathParts = nodePath.split("/"); + if (node.path) { + pathParts = node.path.split("/"); } return ( @@ -33,17 +38,17 @@ export function Console({ id }: { id: string }) { {!isRoot && ( { - navigator.clipboard.writeText(id); + navigator.clipboard.writeText(node.id); }} /> { - navigator.clipboard.writeText(nodePath); + navigator.clipboard.writeText(node.path); }} /> diff --git a/src/web/src/components/parts.module.css b/src/web/src/components/parts.module.css index bfff381..79961e9 100644 --- a/src/web/src/components/parts.module.css +++ b/src/web/src/components/parts.module.css @@ -40,18 +40,6 @@ white-space: nowrap; } -.link { - overflow-wrap: anywhere; -} - -.link:link { - color: #f3715d; -} - -.link:visited { - color: #f3715d; -} - .paragraph { display: block; margin-block-start: 1em; @@ -113,18 +101,6 @@ border-radius: 8px; } -.container { - position: relative; - - padding-top: 0; - padding-bottom: 0; - padding-left: 30px; - padding-right: 30px; - background-color: #111111; - border: 3px solid #111111; - border-radius: 8px; -} - .graphviz { justify-content: center; display: flex; diff --git a/src/web/src/components/parts.tsx b/src/web/src/components/parts.tsx index 4b4d2ff..734ad42 100644 --- a/src/web/src/components/parts.tsx +++ b/src/web/src/components/parts.tsx @@ -2,7 +2,13 @@ import React from "react"; import { Node, NodeAstPart } from "../types/node"; import { nodeAstAtom, childNodesAtom } from "./atoms"; -import { Spacer } from "./util"; +import { + ClickableContainer, + Container, + ExternalLink, + InternalLink, + Spacer, +} from "./util"; import { move } from "../api/graph"; import styles from "./parts.module.css"; @@ -12,7 +18,7 @@ import { darcula as SyntaxHighlighterStyle } from "react-syntax-highlighter/dist import { useState, useEffect, useMemo } from "react"; import { useAtomValue } from "jotai"; import { graphviz } from "d3-graphviz"; -import { useNavigate, Link as RouterLink } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useDroppable, useDraggable, @@ -164,9 +170,17 @@ export function NodePart({ part }: { part: NodeAstPart }) { case "code_block": return ; case "link": - return ; + return ( + + + + ); case "graph_link": - return ; + return ( + + + + ); case "list": return ; case "list_item": @@ -364,26 +378,6 @@ function CodeBlock({ part }: { part: NodeAstPart }) { ); } -function Link({ part }: { part: NodeAstPart }) { - const href = part.attributes["destination"] as string; - - return ( - - - - ); -} - -function GraphLink({ part }: { part: NodeAstPart }) { - const href = part.attributes["destination"] as string; - - return ( - - - - ); -} - function List({ part }: { part: NodeAstPart }) { // {(part.attributes["ordered"] as boolean) &&

ordered

} // {(part.attributes["definition"] as boolean) &&

definition

} @@ -664,20 +658,3 @@ function NodePartChildren({ part }: { part: NodeAstPart }) { ); } - -function ClickableContainer( - props: React.PropsWithChildren<{ onClick?: () => void }>, -) { - return ( -
- {props.children} -
- ); -} - -function Container(props: React.PropsWithChildren<{}>) { - return
{props.children}
; -} diff --git a/src/web/src/components/util.module.css b/src/web/src/components/util.module.css new file mode 100644 index 0000000..c099222 --- /dev/null +++ b/src/web/src/components/util.module.css @@ -0,0 +1,35 @@ +.link { + overflow-wrap: anywhere; +} + +.link:link { + color: #f3715d; +} + +.link:visited { + color: #f3715d; +} + +.container { + position: relative; + + padding-top: 0; + padding-bottom: 0; + padding-left: 30px; + padding-right: 30px; + background-color: #111111; + border: 3px solid #111111; + border-radius: 8px; +} + +.clickable { + cursor: pointer; +} + +.clickable:hover { + border: 3px solid #841a84; +} + +.clickable:active { + border: 3px solid #ffffff; +} diff --git a/src/web/src/components/util.tsx b/src/web/src/components/util.tsx index af8eb82..71a495d 100644 --- a/src/web/src/components/util.tsx +++ b/src/web/src/components/util.tsx @@ -1,3 +1,7 @@ +import styles from "./util.module.css"; + +import { Link as RouterLink } from "react-router-dom"; + export function Spacer({ width = 0, height = 0, @@ -21,3 +25,36 @@ export function Spacer({ return
; } + +export function InternalLink(props: React.PropsWithChildren<{ href: string }>) { + return ( + + {props.children} + + ); +} + +export function ExternalLink(props: React.PropsWithChildren<{ href: string }>) { + return ( + + {props.children} + + ); +} + +export function Container(props: React.PropsWithChildren<{}>) { + return
{props.children}
; +} + +export function ClickableContainer( + props: React.PropsWithChildren<{ onClick?: () => void }>, +) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/web/src/index.tsx b/src/web/src/index.tsx index 6b1f3be..35489d7 100644 --- a/src/web/src/index.tsx +++ b/src/web/src/index.tsx @@ -1,18 +1,40 @@ import React from "react"; + +import { View } from "./pages/view"; +import { SignIn } from "./pages/signin"; +import { Landing } from "./pages/landing"; + import ReactDOM from "react-dom/client"; import "@fontsource/roboto-mono/500.css"; import "./index.css"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import { View } from "./view"; import { ThemeProvider, createTheme } from "@mui/material/styles"; const theme = createTheme({ typography: { fontFamily: "Roboto Mono", }, + palette: { + mode: "dark", + text: { + primary: "#acacac", + secondary: "#acacac", + }, + primary: { + main: "#841a84", + }, + }, }); const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/signin", + element: , + }, { path: "/view/*", element: , diff --git a/src/web/src/pages/landing.tsx b/src/web/src/pages/landing.tsx new file mode 100644 index 0000000..e685740 --- /dev/null +++ b/src/web/src/pages/landing.tsx @@ -0,0 +1,15 @@ +import { InternalLink } from "../components/util"; + +export function Landing() { + return ( +
+

Landing

+
+ Sign In +
+
+ View +
+
+ ); +} diff --git a/src/web/src/pages/signin.tsx b/src/web/src/pages/signin.tsx new file mode 100644 index 0000000..bab6b0b --- /dev/null +++ b/src/web/src/pages/signin.tsx @@ -0,0 +1,97 @@ +import { signIn } from "../api/auth"; + +import { sessionAtom } from "../components/atoms"; + +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Container, Spacer } from "../components/util"; +import { useNavigate } from "react-router-dom"; +import { useSetAtom } from "jotai"; + +export function SignIn() { + const navigate = useNavigate(); + const setSession = useSetAtom(sessionAtom); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + console.log({ + email: data.get("username"), + password: data.get("password"), + }); + + signIn(data.get("username") as string, data.get("password") as string).then( + (token) => { + localStorage.setItem("rat_api_token", token); + setSession({ + token: token, + }); + navigate("/view"); + }, + ); + }; + + return ( +
+
+ + + + + Sign in + + + + + + + + + +
+
+ ); +} diff --git a/src/web/src/pages/view.tsx b/src/web/src/pages/view.tsx new file mode 100644 index 0000000..cb8c1cb --- /dev/null +++ b/src/web/src/pages/view.tsx @@ -0,0 +1,81 @@ +import React from "react"; + +import { NodeContent, ChildNodes } from "../components/parts"; +import { Console } from "../components/console"; +import { + sessionAtom, + nodeAtom, + nodePathAtom, + nodeAstAtom, + childNodesAtom, +} from "../components/atoms"; +import { read } from "../api/node"; + +import { useEffect } from "react"; +import { useAtom, useSetAtom, useAtomValue } from "jotai"; +import { useLoaderData, useNavigate } from "react-router-dom"; + +export function View() { + const navigate = useNavigate(); + + const session = useAtomValue(sessionAtom); + const [node, setNode] = useAtom(nodeAtom); + const setNodeAst = useSetAtom(nodeAstAtom); + const setChildNodes = useSetAtom(childNodesAtom); + const setNodePath = useSetAtom(nodePathAtom); + + const path = useLoaderData() as string; // path from router + + const [error, setError] = React.useState(undefined); + + useEffect(() => { + if (!session) { + navigate("/signin"); + + return; + } + + read(session.token, path) + .then((node) => { + setNode(node); + setNodeAst(node.ast); + setChildNodes(node.childNodes); + setNodePath(node.path); + + document.title = node.name; + }) + .catch((err) => { + if (err.response) { + setError(err.response.data.error); + + return; + } + + setError(err.message); + }); + }, [ + session, + path, + setNode, + setNodeAst, + setChildNodes, + setNodePath, + navigate, + ]); + + if (error) { + return <>{error}; + } + + if (!node) { + return <> ; + } + + return ( + <> + + + + + ); +} diff --git a/src/web/src/types/session.tsx b/src/web/src/types/session.tsx new file mode 100644 index 0000000..2dbafd5 --- /dev/null +++ b/src/web/src/types/session.tsx @@ -0,0 +1,3 @@ +export interface Session { + token: string; +} diff --git a/src/web/src/view.tsx b/src/web/src/view.tsx deleted file mode 100644 index c096ee2..0000000 --- a/src/web/src/view.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; - -import { NodeContent, ChildNodes } from "./components/parts"; -import { Console } from "./components/console"; -import { - nodeAtom, - nodePathAtom, - nodeAstAtom, - childNodesAtom, -} from "./components/atoms"; - -import { useEffect } from "react"; -import { useAtom, useSetAtom } from "jotai"; -import { useLoaderData } from "react-router-dom"; - -import { read } from "./api/node"; - -export function View() { - const [node, setNode] = useAtom(nodeAtom); - const setNodeAst = useSetAtom(nodeAstAtom); - const setChildNodes = useSetAtom(childNodesAtom); - const setNodePath = useSetAtom(nodePathAtom); - - const path = useLoaderData() as string; // path from router - - useEffect(() => { - read(path).then((node) => { - setNode(node); - setNodeAst(node.ast); - setChildNodes(node.childNodes); - setNodePath(node.path); - - document.title = node.name; - }); - }, [path, setNode, setNodeAst, setChildNodes, setNodePath]); - - if (!node) { - return <> ; - } - - return ( - <> - - - - - ); -} From 8098083964308a921a3d7f0f174734cc452c0b6d Mon Sep 17 00:00:00 2001 From: ruzv Date: Thu, 28 Dec 2023 14:43:05 +0200 Subject: [PATCH 4/8] rat-0006: Make providers a sub dir of services --- src/graph/{ => services}/provider/filesystem/filesystem.go | 0 src/graph/{ => services}/provider/pathcache/pathcache.go | 0 src/graph/{ => services}/provider/provider.go | 6 +++--- src/graph/{ => services}/provider/root/root.go | 0 src/graph/{ => services}/provider/root/root_test.go | 0 src/graph/services/services.go | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) rename src/graph/{ => services}/provider/filesystem/filesystem.go (100%) rename src/graph/{ => services}/provider/pathcache/pathcache.go (100%) rename src/graph/{ => services}/provider/provider.go (86%) rename src/graph/{ => services}/provider/root/root.go (100%) rename src/graph/{ => services}/provider/root/root_test.go (100%) diff --git a/src/graph/provider/filesystem/filesystem.go b/src/graph/services/provider/filesystem/filesystem.go similarity index 100% rename from src/graph/provider/filesystem/filesystem.go rename to src/graph/services/provider/filesystem/filesystem.go diff --git a/src/graph/provider/pathcache/pathcache.go b/src/graph/services/provider/pathcache/pathcache.go similarity index 100% rename from src/graph/provider/pathcache/pathcache.go rename to src/graph/services/provider/pathcache/pathcache.go diff --git a/src/graph/provider/provider.go b/src/graph/services/provider/provider.go similarity index 86% rename from src/graph/provider/provider.go rename to src/graph/services/provider/provider.go index cb7932c..932294a 100644 --- a/src/graph/provider/provider.go +++ b/src/graph/services/provider/provider.go @@ -3,9 +3,9 @@ package provider import ( "github.com/pkg/errors" "rat/graph" - "rat/graph/provider/filesystem" - "rat/graph/provider/pathcache" - "rat/graph/provider/root" + "rat/graph/services/provider/filesystem" + "rat/graph/services/provider/pathcache" + "rat/graph/services/provider/root" "rat/logr" ) diff --git a/src/graph/provider/root/root.go b/src/graph/services/provider/root/root.go similarity index 100% rename from src/graph/provider/root/root.go rename to src/graph/services/provider/root/root.go diff --git a/src/graph/provider/root/root_test.go b/src/graph/services/provider/root/root_test.go similarity index 100% rename from src/graph/provider/root/root_test.go rename to src/graph/services/provider/root/root_test.go diff --git a/src/graph/services/services.go b/src/graph/services/services.go index d7c3e20..2ffb621 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -6,8 +6,8 @@ import ( "github.com/pkg/errors" "rat/graph" "rat/graph/index" - "rat/graph/provider" "rat/graph/services/auth" + "rat/graph/services/provider" "rat/graph/services/urlresolve" "rat/graph/sync" "rat/logr" From 315c7aa197f43ffda246815696eb27b9a65ca6c7 Mon Sep 17 00:00:00 2001 From: ruzv Date: Fri, 29 Dec 2023 18:07:22 +0200 Subject: [PATCH 5/8] rat-0006: Extended auth server side implementation --- src/graph/graph.go | 16 +- src/graph/services/auth/auth.go | 172 ++++++++++------ src/graph/services/auth/scope.go | 164 ++++++++++++++++ src/graph/services/auth/token.go | 8 +- src/graph/services/provider/access/access.go | 73 +++++++ .../services/provider/pathcache/pathcache.go | 4 +- src/graph/services/provider/provider.go | 2 +- src/graph/services/services.go | 4 +- src/handler/authhttp/authhttp.go | 183 +++++------------- src/handler/router/router.go | 7 +- .../src/components/buttons/buttons.module.css | 1 - 11 files changed, 429 insertions(+), 205 deletions(-) create mode 100644 src/graph/services/auth/scope.go create mode 100644 src/graph/services/provider/access/access.go diff --git a/src/graph/graph.go b/src/graph/graph.go index 8c8fc9e..71301f4 100644 --- a/src/graph/graph.go +++ b/src/graph/graph.go @@ -7,6 +7,7 @@ import ( "github.com/gofrs/uuid" "github.com/pkg/errors" + "rat/graph/services/auth" "rat/graph/util" pathutil "rat/graph/util/path" ) @@ -23,7 +24,6 @@ var allowedPathNameSymbols = regexp.MustCompile(`[a-zA-Z0-9_\-]`) // Node describes a single node. type Node struct { - // Name string `json:"name"` Path pathutil.NodePath `json:"path"` Header NodeHeader `json:"header"` Content string `json:"content"` @@ -32,6 +32,7 @@ type Node struct { // NodeHeader describes info stored in nodes header. type NodeHeader struct { ID uuid.UUID `yaml:"id"` + Access *auth.Scopes `yaml:"access"` Name string `yaml:"name,omitempty"` Weight int `yaml:"weight,omitempty"` Template *NodeTemplate `yaml:"template,omitempty"` @@ -61,6 +62,19 @@ func (n *Node) Name() string { return n.Path.Name() } +func (n *Node) Access(p Provider) ([]auth.Scope, error) { + if n.Header.Access != nil { + return n.Header.Access.Slice(), nil + } + + parent, err := n.Parent(p) + if err != nil { + return nil, errors.Wrap(err, "failed to get parent") + } + + return parent.Access(p) +} + // GetLeafs returns all leafs of node. func (n *Node) GetLeafs(r Reader) ([]*Node, error) { leafs, err := r.GetLeafs(n.Path) diff --git a/src/graph/services/auth/auth.go b/src/graph/services/auth/auth.go index e1df90d..828b5f6 100644 --- a/src/graph/services/auth/auth.go +++ b/src/graph/services/auth/auth.go @@ -3,29 +3,29 @@ package auth import ( "time" + "github.com/golang-jwt/jwt/v5" "github.com/pkg/errors" - "gopkg.in/yaml.v2" + "golang.org/x/crypto/bcrypt" + "rat/logr" ) -//nolint:gochecknoglobals -var ( - // Owner role gives a user unlimited access to the graph. - Owner Role = "owner" - // Member role gives a user full access to graph nodes marked with - // access: member and its sub-nodes. - Member Role = "member" - // Viewer role gives a user read access to graph nodes marked with - // access: viewer and its sub-nodes. - Viewer Role = "viewer" - // Visitor role gives read access unauthenticated users to graph nodes - // marked with access: visitor and its sub-nodes. - Visitor Role = "visitor" -) +// Config defines configuration params for authentication. +type Config struct { + Users []*User `yaml:"users"` + Token *TokenConfig `yaml:"token" validate:"nonzero"` +} -var _ yaml.Unmarshaler = (*Role)(nil) +// TokenConfig defines configuration params for JWT token generation. +type TokenConfig struct { + Secret string `yaml:"secret" validate:"nonzero"` + Expiration time.Duration `yaml:"expiration" validate:"nonzero"` +} -// Role defines a user role. -type Role string +// User defines a user with credentials and role. +type User struct { + Credentials `yaml:",inline"` + Scopes *Scopes `yaml:"scopes" validate:"nonzero"` +} // Credentials defines users username and password. type Credentials struct { @@ -33,57 +33,117 @@ type Credentials struct { Password string `yaml:"password" validate:"nonzero"` } -// User defines a user with credentials and role. -type User struct { - Credentials `yaml:",inline"` - Role Role `yaml:"role" validate:"nonzero"` +// func (r Role) Allowed(access Role, scope string) bool { +// if r == Owner { +// return true +// } +// +// // owner scopes +// +// scpoes := map[Role][]string{ +// Owner: {"read", "write"}, +// Member: {"read", "write"}, +// Viewer: {"read"}, +// Visitor: {"read"}, +// } +// } + +type TokenControl struct { + tokenConfig *TokenConfig + users map[string]*User + log *logr.LogR } -// TokenConfig defines configuration params for JWT token generation. -type TokenConfig struct { - Secret string `yaml:"secret" validate:"nonzero"` - Expiration time.Duration `yaml:"expiration" validate:"nonzero"` +func NewTokenControl(config *Config, log *logr.LogR) *TokenControl { + log = log.Prefix("token-control") + + lg := log.Group(logr.LogLevelInfo) + defer lg.Close() + + lg.Log("users:") + + users := make(map[string]*User) + for _, user := range config.Users { + users[user.Username] = user + + lg.Log(" %s", user.Username) + } + + return &TokenControl{ + tokenConfig: config.Token, + users: users, + log: log, + } } -// Config defines configuration params for authentication. -type Config struct { - Owner *Credentials `yaml:"owner" validate:"nonzero"` - Users []*User `yaml:"users"` - Token *TokenConfig `yaml:"token" validate:"nonzero"` +func (tc *TokenControl) Generate( + username, password string, +) (string, *Token, error) { + user, ok := tc.users[username] + if !ok { + return "", nil, errors.Errorf("user %q not found", username) + } + + err := bcrypt.CompareHashAndPassword( + []byte(user.Password), + []byte(password), + ) + if err != nil { + return "", nil, errors.Wrap(err, "failed to authenticate user") + } + + token := &Token{ + Username: user.Username, + Expires: time.Now().Add(tc.tokenConfig.Expiration).Unix(), + Scopes: user.Scopes.Slice(), + } + + signed, err := jwt.NewWithClaims( + jwt.SigningMethodHS512, + token.ToMapClaims(), + ). + SignedString([]byte(tc.tokenConfig.Secret)) + if err != nil { + return "", nil, errors.Wrap(err, "failed to sign token") + } + + return signed, token, nil } -// AllUsers returns all users, with roles and credentials, including the owner -// user. -func (c *Config) AllUsers() []*User { - return append( - []*User{ - { - Credentials: *c.Owner, - Role: Owner, - }, +func (tc *TokenControl) Validate(signed string) (*Token, error) { + token, err := jwt.Parse( + signed, + func(token *jwt.Token) (any, error) { + return []byte(tc.tokenConfig.Secret), nil }, - c.Users..., + jwt.WithValidMethods([]string{"HS512"}), ) -} + if err != nil { + return nil, errors.Wrap(err, "failed to parse token") + } -// UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid -// values. -func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { - var raw string + if !token.Valid { + return nil, errors.New("token not valid") + } - err := unmarshal(&raw) - if err != nil { - return errors.Wrap(err, "failed to unmarshal role") + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("failed to parse claims") } - role := Role(raw) + t, err := FromMapClaims(claims) + if err != nil { + return nil, errors.Wrap(err, "failed to parse token from claims") + } - switch role { - case Owner, Member, Viewer: - *r = role + if t.Expired() { + return nil, errors.New("token expired") + } - return nil - default: - return errors.Errorf("invalid role: %s", raw) + _, ok = tc.users[t.Username] + if !ok { + return nil, errors.New("user not found") } + + return t, nil } diff --git a/src/graph/services/auth/scope.go b/src/graph/services/auth/scope.go new file mode 100644 index 0000000..f03cb40 --- /dev/null +++ b/src/graph/services/auth/scope.go @@ -0,0 +1,164 @@ +package auth + +import ( + "github.com/pkg/errors" + "gopkg.in/yaml.v2" +) + +var _ yaml.Unmarshaler = (*Role)(nil) + +const ( + // Owner role gives a user unlimited access to the graph. + Owner Role = "owner" + // Member role gives a user full access to graph nodes marked with + // access: member and its sub-nodes. + Member Role = "member" + // Viewer role gives a user read access to graph nodes marked with + // access: viewer and its sub-nodes. + Viewer Role = "viewer" + // Visitor role gives read access unauthenticated users to graph nodes + // marked with access: visitor and its sub-nodes. + Visitor Role = "visitor" +) + +const ( + GraphOwnerNodeRead Scope = "graph_owner_node_read" + GraphOwnerNodeWrite Scope = "graph_owner_node_write" + + GraphMemberNodeRead Scope = "graph_member_node_read" + GraphMemberNodeWrite Scope = "graph_member_node_write" + + GraphVisitorNodeRead Scope = "graph_visitor_node_read" + GraphVisitorNodeWrite Scope = "graph_visitor_node_write" +) + +var validScopes = map[Scope]bool{ + GraphOwnerNodeRead: true, + GraphOwnerNodeWrite: true, + GraphMemberNodeRead: true, + GraphMemberNodeWrite: true, + GraphVisitorNodeRead: true, + GraphVisitorNodeWrite: true, +} + +// Role defines a user role - an alias for a list of scopes. +type Role string + +// Scope defines a single access scope. +type Scope string + +// Scopes defines a list of access scopes. Can be unmarshaled from string +// - a predefined role alias, or a list of strings - list of scopes. +type Scopes struct { + scopes []Scope +} + +func (s Scopes) Slice() []Scope { + return s.scopes +} + +func (r Role) Scopes() []Scope { + switch r { + case Owner: + return []Scope{ + GraphOwnerNodeRead, + GraphOwnerNodeWrite, + GraphMemberNodeRead, + GraphMemberNodeWrite, + GraphVisitorNodeRead, + GraphVisitorNodeWrite, + } + case Member: + return []Scope{ + GraphMemberNodeRead, + GraphMemberNodeWrite, + GraphVisitorNodeRead, + GraphVisitorNodeWrite, + } + case Viewer: + return []Scope{ + GraphMemberNodeRead, + GraphVisitorNodeRead, + } + case Visitor: + return []Scope{ + GraphVisitorNodeRead, + } + + default: + return []Scope{} + } +} + +// UnmarshalYAML implements yaml.Unmarshaler for Scopes to unmarshal either +// string or list of strings. +func (s *Scopes) UnmarshalYAML(unmarshal func(any) error) error { + err := unmarshal(&([]string{})) + if err != nil { + + var role Role + + err = unmarshal(&role) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as role") + } + + s.scopes = role.Scopes() + + return nil + } + + scopes := []Scope{} + + err = unmarshal(&scopes) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as list") + } + + s.scopes = scopes + + return nil +} + +// UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid +// values. +func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + + err := unmarshal(&raw) + if err != nil { + return errors.Wrap(err, "failed to unmarshal role") + } + + role := Role(raw) + + switch role { + case Owner, Member, Viewer: + *r = role + + return nil + default: + return errors.Errorf("invalid role: %s", raw) + } +} + +// UnmarshalYAML implements yaml.Unmarshaler for Scope type to check for valid +// values. +func (s *Scope) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + + err := unmarshal(&raw) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scope") + } + + scope := Scope(raw) + + if !validScopes[scope] { + return errors.Errorf("invalid scope: %s", raw) + } + + *s = scope + + return nil +} diff --git a/src/graph/services/auth/token.go b/src/graph/services/auth/token.go index 64dc39c..4d5028f 100644 --- a/src/graph/services/auth/token.go +++ b/src/graph/services/auth/token.go @@ -10,9 +10,9 @@ import ( // Token defines data stored in JWT token. type Token struct { - Username string `mapstructure:"username"` - Expires int64 `mapstructure:"expires"` - Role Role `mapstructure:"role"` + Username string `mapstructure:"username"` + Expires int64 `mapstructure:"expires"` + Scopes []Scope `mapstructure:"scopes"` } // FromMapClaims converts jwt.MapClaims to token claims. @@ -32,7 +32,7 @@ func (t Token) ToMapClaims() jwt.MapClaims { return jwt.MapClaims{ "username": t.Username, "expires": t.Expires, - "role": t.Role, + "scopes": t.Scopes, } } diff --git a/src/graph/services/provider/access/access.go b/src/graph/services/provider/access/access.go new file mode 100644 index 0000000..295cfb5 --- /dev/null +++ b/src/graph/services/provider/access/access.go @@ -0,0 +1,73 @@ +package access + +import ( + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "rat/graph" + "rat/graph/services/auth" + pathutil "rat/graph/util/path" + "rat/logr" +) + +var _ graph.Provider = (*Provider)(nil) + +var ErrAccessDenied = errors.New("access denied") + +type Provider struct { + base graph.Provider + log *logr.LogR + role auth.Role +} + +// NewProvider creates a new filesystem graph provider. +func NewProvider( + base graph.Provider, + log *logr.LogR, + role auth.Role, +) *Provider { + return &Provider{ + base: base, + log: log.Prefix("access"), + role: role, + } +} + +// GetByID reads node by id, first checkint if role configured for provides +// allows access to node. +func (p *Provider) GetByID(id uuid.UUID) (*graph.Node, error) { + n, err := p.base.GetByID(id) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by ID") + } + + // access, err := n.Access(p.base) + // if err != nil { + // return nil, errors.Wrap(err, "failed to get node access") + // } + + // if !p.role.Allowed(access) { + // return nil, ErrAccessDenied + // } + + return n, nil +} + +func (p *Provider) GetByPath(path pathutil.NodePath) (*graph.Node, error) { + return nil, nil +} + +func (p *Provider) GetLeafs(path pathutil.NodePath) ([]*graph.Node, error) { + return nil, nil +} + +func (p *Provider) Move(id uuid.UUID, path pathutil.NodePath) error { + return nil +} + +func (p *Provider) Write(n *graph.Node) error { + return nil +} + +func (p *Provider) Delete(n *graph.Node) error { + return nil +} diff --git a/src/graph/services/provider/pathcache/pathcache.go b/src/graph/services/provider/pathcache/pathcache.go index 80dde24..67fe557 100644 --- a/src/graph/services/provider/pathcache/pathcache.go +++ b/src/graph/services/provider/pathcache/pathcache.go @@ -20,8 +20,8 @@ type Provider struct { cacheMu sync.Mutex } -// NewPathCache returns a new PathCache. -func NewPathCache(base graph.Provider, log *logr.LogR) *Provider { +// NewProvider returns a new PathCache. +func NewProvider(base graph.Provider, log *logr.LogR) *Provider { log = log.Prefix("pathcache") log.Infof("enabled") diff --git a/src/graph/services/provider/provider.go b/src/graph/services/provider/provider.go index 932294a..7ebd267 100644 --- a/src/graph/services/provider/provider.go +++ b/src/graph/services/provider/provider.go @@ -31,7 +31,7 @@ func New( //nolint:ireturn // i know better. var p graph.Provider = root.NewProvider(fs, c.Root) if c.EnablePathCache == nil || *c.EnablePathCache { - p = pathcache.NewPathCache(p, log) + p = pathcache.NewProvider(p, log) } return p, nil diff --git a/src/graph/services/services.go b/src/graph/services/services.go index 2ffb621..b3226e0 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -27,7 +27,7 @@ type GraphServices struct { Syncer *sync.Syncer Index *index.GraphIndex URLResolver *urlresolve.Resolver - Auth *auth.Config + Auth *auth.TokenControl log *logr.LogR } @@ -39,7 +39,7 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { err error gs = &GraphServices{ URLResolver: urlresolve.NewResolver(c.URLResolver, log), - Auth: c.Auth, + Auth: auth.NewTokenControl(c.Auth, log), log: log, } ) diff --git a/src/handler/authhttp/authhttp.go b/src/handler/authhttp/authhttp.go index d064edd..e40a1bb 100644 --- a/src/handler/authhttp/authhttp.go +++ b/src/handler/authhttp/authhttp.go @@ -3,40 +3,28 @@ package auth import ( "net/http" "strings" - "time" - "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/pkg/errors" - "golang.org/x/crypto/bcrypt" - "rat/graph/services/auth" - "rat/graph/util" + "rat/graph/services" "rat/handler/httputil" "rat/logr" ) type handler struct { - log *logr.LogR - users map[string]*auth.User - tokenConfig *auth.TokenConfig + log *logr.LogR + gs *services.GraphServices } // RegisterRoutes registers auth routes on given router. func RegisterRoutes( - router *mux.Router, - log *logr.LogR, - users []*auth.User, - tokenConfig *auth.TokenConfig, + router *mux.Router, log *logr.LogR, gs *services.GraphServices, ) (mux.MiddlewareFunc, error) { log = log.Prefix("auth") h := &handler{ log: log, - users: util.ObjectMap( - users, - func(u *auth.User) string { return u.Username }, - ), - tokenConfig: tokenConfig, + gs: gs, } authRouter := router.PathPrefix("/auth").Subrouter() @@ -68,41 +56,10 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { ) } - user, ok := h.users[body.Username] - if !ok { - return httputil.Error( - http.StatusBadRequest, - errors.New("failed to authenticate user"), - ) - } - - err = bcrypt.CompareHashAndPassword( - []byte(user.Password), - []byte(body.Password), - ) + signed, _, err := h.gs.Auth.Generate(body.Username, body.Password) if err != nil { - h.log.Warnf( - "failed to authenticate user - %q: %s", user.Username, err.Error(), - ) - return httputil.Error( - http.StatusBadRequest, - errors.New("failed to authenticate user"), - ) - } - - token, err := jwt.NewWithClaims( - jwt.SigningMethodHS512, - auth.Token{ - Username: user.Username, - Expires: time.Now().Add(h.tokenConfig.Expiration).Unix(), - Role: user.Role, - }.ToMapClaims(), - ).SignedString([]byte(h.tokenConfig.Secret)) - if err != nil { - return httputil.Error( - http.StatusBadRequest, - errors.Wrap(err, "failed to sign token"), + http.StatusBadRequest, errors.Wrap(err, "failed to generate token"), ) } @@ -112,7 +69,7 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { struct { Token string `json:"token"` }{ - Token: token, + Token: signed, }, ) if err != nil { @@ -126,92 +83,54 @@ func (h *handler) auth(w http.ResponseWriter, r *http.Request) error { } func (h *handler) authMW(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodOptions { - next.ServeHTTP(w, r) - - return - } - - headerParts := strings.Fields(r.Header.Get("Authorization")) + return http.HandlerFunc(httputil.Wrap( + func(w http.ResponseWriter, r *http.Request) error { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + + return nil + } + + signed, err := getToken(r) + if err != nil { + return httputil.Error( + http.StatusBadRequest, + errors.Wrap(err, "failed to get token"), + ) + } + + _, err = h.gs.Auth.Validate(signed) + if err != nil { + return httputil.Error( + http.StatusUnauthorized, + errors.Wrap(err, "failed to validate token"), + ) + } - if len(headerParts) != 2 || - strings.EqualFold("Bearer", headerParts[0]) { - h.log.Debugf( - "malformed authorization header: %q", - r.Header.Get("Authorization"), - ) + next.ServeHTTP(w, r) - httputil.WriteError( - w, http.StatusBadRequest, "invalid Authorization header", - ) + return nil + }, + h.log, + "auth-middleware", + )) +} - return - } +func getToken(r *http.Request) (string, error) { + headerParts := strings.Fields(r.Header.Get("Authorization")) - token, err := jwt.Parse( - headerParts[1], - func(token *jwt.Token) (any, error) { - return []byte(h.tokenConfig.Secret), nil - }, - jwt.WithValidMethods([]string{"HS512"}), + if len(headerParts) != 2 { + return "", errors.New( + `invalid Authorization header, expected 2 parts - "Bearer "`, ) - if err != nil { - h.log.Debugf("failed to parse token: %s", err.Error()) - - httputil.WriteError( - w, http.StatusBadRequest, "invalid authorization token", - ) - - return - } - - if !token.Valid { - h.log.Debugf("token not valid") - - httputil.WriteError( - w, http.StatusBadRequest, "invalid authorization token", - ) - - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - h.log.Debugf("failed to parse claims") - - httputil.WriteError( - w, http.StatusBadRequest, "invalid authorization token", - ) - - return - } - - tc, err := auth.FromMapClaims(claims) - if err != nil { - h.log.Debugf("failed to parse token claims") - - httputil.WriteError( - w, http.StatusBadRequest, "invalid authorization token", - ) - - return - } - - if tc.Expired() { - h.log.Debugf( - "token expired, expires - %d, now - %d", - tc.Expires, - time.Now().Unix(), - ) - - httputil.WriteError( - w, http.StatusUnauthorized, "token expired", - ) + } - return - } + if !strings.EqualFold("Bearer", headerParts[0]) { + return "", errors.Errorf( + `invalid Authorization token kind - %q, expected "Bearer"`, + headerParts[0], + ) + } - next.ServeHTTP(w, r) - }) + return headerParts[1], nil } diff --git a/src/handler/router/router.go b/src/handler/router/router.go index 95b4c1b..408a1cc 100644 --- a/src/handler/router/router.go +++ b/src/handler/router/router.go @@ -61,12 +61,7 @@ func NewRouter( if gs.Auth != nil { log.Infof("registering auth routes") - mw, err := auth.RegisterRoutes( - exposedRouter, - log, - gs.Auth.AllUsers(), - gs.Auth.Token, - ) + mw, err := auth.RegisterRoutes(exposedRouter, log, gs) if err != nil { return nil, errors.Wrap(err, "failed to register auth routes") } diff --git a/src/web/src/components/buttons/buttons.module.css b/src/web/src/components/buttons/buttons.module.css index 8b73cc7..5ae4e31 100644 --- a/src/web/src/components/buttons/buttons.module.css +++ b/src/web/src/components/buttons/buttons.module.css @@ -38,4 +38,3 @@ flex-wrap: wrap; gap: 6px; } - From 833f128ece0fb485cf96d433a91f73288d5902de Mon Sep 17 00:00:00 2001 From: ruzv Date: Sun, 31 Dec 2023 14:35:24 +0200 Subject: [PATCH 6/8] rat-0006: Rat server auth implementation --- src/graph/graph.go | 15 +- src/graph/services/auth/auth.go | 55 ++-- src/graph/services/auth/scope.go | 310 ++++++++++++------- src/graph/services/auth/token.go | 17 +- src/graph/services/provider/access/access.go | 191 +++++++++++- src/graph/services/provider/root/root.go | 7 + src/graph/services/services.go | 6 +- src/graph/util/path/path.go | 2 +- src/handler/authhttp/authhttp.go | 8 +- src/handler/graphhttp/nodeshttp/nodeshttp.go | 73 ++++- 10 files changed, 508 insertions(+), 176 deletions(-) diff --git a/src/graph/graph.go b/src/graph/graph.go index 71301f4..73f6b1c 100644 --- a/src/graph/graph.go +++ b/src/graph/graph.go @@ -1,6 +1,7 @@ package graph import ( + "fmt" "regexp" "sort" "strings" @@ -32,7 +33,7 @@ type Node struct { // NodeHeader describes info stored in nodes header. type NodeHeader struct { ID uuid.UUID `yaml:"id"` - Access *auth.Scopes `yaml:"access"` + Domain auth.Domain `yaml:"domain"` Name string `yaml:"name,omitempty"` Weight int `yaml:"weight,omitempty"` Template *NodeTemplate `yaml:"template,omitempty"` @@ -62,17 +63,19 @@ func (n *Node) Name() string { return n.Path.Name() } -func (n *Node) Access(p Provider) ([]auth.Scope, error) { - if n.Header.Access != nil { - return n.Header.Access.Slice(), nil +func (n *Node) Domain(p Provider) (auth.Domain, error) { + if n.Header.Domain != "" { + return n.Header.Domain, nil } parent, err := n.Parent(p) if err != nil { - return nil, errors.Wrap(err, "failed to get parent") + return "", errors.Wrap(err, "failed to get parent") } - return parent.Access(p) + fmt.Printf("parent: %v\n", parent.Path) + + return parent.Domain(p) } // GetLeafs returns all leafs of node. diff --git a/src/graph/services/auth/auth.go b/src/graph/services/auth/auth.go index 828b5f6..dc15b4d 100644 --- a/src/graph/services/auth/auth.go +++ b/src/graph/services/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "time" "github.com/golang-jwt/jwt/v5" @@ -9,10 +10,15 @@ import ( "rat/logr" ) +type ctxKey string + +const AuthTokenCtxKey ctxKey = "auth-token" + // Config defines configuration params for authentication. type Config struct { - Users []*User `yaml:"users"` - Token *TokenConfig `yaml:"token" validate:"nonzero"` + Users []*User `yaml:"users"` + Roles map[Role][]*Scope `yaml:"roles"` + Token *TokenConfig `yaml:"token" validate:"nonzero"` } // TokenConfig defines configuration params for JWT token generation. @@ -33,28 +39,14 @@ type Credentials struct { Password string `yaml:"password" validate:"nonzero"` } -// func (r Role) Allowed(access Role, scope string) bool { -// if r == Owner { -// return true -// } -// -// // owner scopes -// -// scpoes := map[Role][]string{ -// Owner: {"read", "write"}, -// Member: {"read", "write"}, -// Viewer: {"read"}, -// Visitor: {"read"}, -// } -// } - type TokenControl struct { - tokenConfig *TokenConfig users map[string]*User + roles map[Role][]*Scope + tokenConfig *TokenConfig log *logr.LogR } -func NewTokenControl(config *Config, log *logr.LogR) *TokenControl { +func NewTokenControl(config *Config, log *logr.LogR) (*TokenControl, error) { log = log.Prefix("token-control") lg := log.Group(logr.LogLevelInfo) @@ -66,14 +58,31 @@ func NewTokenControl(config *Config, log *logr.LogR) *TokenControl { for _, user := range config.Users { users[user.Username] = user - lg.Log(" %s", user.Username) + b, err := json.MarshalIndent( + struct { + Username string `json:"username"` + Scopes []*Scope `json:"scopes"` + }{ + Username: user.Username, + Scopes: user.Scopes.Get(config.Roles), + }, + "", + " ", + ) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal user for log") + } + + lg.Log("%s", string(b)) + } return &TokenControl{ - tokenConfig: config.Token, users: users, + roles: config.Roles, + tokenConfig: config.Token, log: log, - } + }, nil } func (tc *TokenControl) Generate( @@ -95,7 +104,7 @@ func (tc *TokenControl) Generate( token := &Token{ Username: user.Username, Expires: time.Now().Add(tc.tokenConfig.Expiration).Unix(), - Scopes: user.Scopes.Slice(), + Scopes: user.Scopes.Get(tc.roles), } signed, err := jwt.NewWithClaims( diff --git a/src/graph/services/auth/scope.go b/src/graph/services/auth/scope.go index f03cb40..96cac5a 100644 --- a/src/graph/services/auth/scope.go +++ b/src/graph/services/auth/scope.go @@ -1,164 +1,256 @@ package auth import ( + "encoding/json" + "strings" + "github.com/pkg/errors" "gopkg.in/yaml.v2" + "rat/graph/util" +) + +// var _ yaml.Unmarshaler = (*Role)(nil) +var ( + _ yaml.Unmarshaler = (*Scopes)(nil) + _ yaml.Unmarshaler = (*Scope)(nil) + _ json.Marshaler = (*Scope)(nil) ) -var _ yaml.Unmarshaler = (*Role)(nil) +// const ( +// // Owner role gives a user unlimited access to the graph. +// Owner Role = "owner" +// // Member role gives a user full access to graph nodes marked with +// // access: member and its sub-nodes. +// Member Role = "member" +// // Viewer role gives a user read access to graph nodes marked with +// // access: viewer and its sub-nodes. +// Viewer Role = "viewer" +// // Visitor role gives read access unauthenticated users to graph nodes +// // marked with access: visitor and its sub-nodes. +// Visitor Role = "visitor" +// ) +// +// const ( +// GraphOwnerNodeRead Scope = "graph_owner_node_read" +// GraphOwnerNodeWrite Scope = "graph_owner_node_write" +// +// GraphMemberNodeRead Scope = "graph_member_node_read" +// GraphMemberNodeWrite Scope = "graph_member_node_write" +// +// GraphVisitorNodeRead Scope = "graph_visitor_node_read" +// GraphVisitorNodeWrite Scope = "graph_visitor_node_write" +// ) +// +// var validScopes = map[Scope]bool{ +// GraphOwnerNodeRead: true, +// GraphOwnerNodeWrite: true, +// GraphMemberNodeRead: true, +// GraphMemberNodeWrite: true, +// GraphVisitorNodeRead: true, +// GraphVisitorNodeWrite: true, +// } const ( - // Owner role gives a user unlimited access to the graph. - Owner Role = "owner" - // Member role gives a user full access to graph nodes marked with - // access: member and its sub-nodes. - Member Role = "member" - // Viewer role gives a user read access to graph nodes marked with - // access: viewer and its sub-nodes. - Viewer Role = "viewer" - // Visitor role gives read access unauthenticated users to graph nodes - // marked with access: visitor and its sub-nodes. - Visitor Role = "visitor" + GraphNode Resource = "graph_node" ) const ( - GraphOwnerNodeRead Scope = "graph_owner_node_read" - GraphOwnerNodeWrite Scope = "graph_owner_node_write" + Read Operation = "read" + Write Operation = "write" +) - GraphMemberNodeRead Scope = "graph_member_node_read" - GraphMemberNodeWrite Scope = "graph_member_node_write" +// Resource defines a resource, that belongs to a domain and on which a +// user can perform operations. +type Resource string - GraphVisitorNodeRead Scope = "graph_visitor_node_read" - GraphVisitorNodeWrite Scope = "graph_visitor_node_write" -) +// Domain defines a access domain a resource has. +type Domain string -var validScopes = map[Scope]bool{ - GraphOwnerNodeRead: true, - GraphOwnerNodeWrite: true, - GraphMemberNodeRead: true, - GraphMemberNodeWrite: true, - GraphVisitorNodeRead: true, - GraphVisitorNodeWrite: true, -} +// Operation defines a operation a user can perform on a resource. +type Operation string // Role defines a user role - an alias for a list of scopes. type Role string -// Scope defines a single access scope. -type Scope string - -// Scopes defines a list of access scopes. Can be unmarshaled from string -// - a predefined role alias, or a list of strings - list of scopes. -type Scopes struct { - scopes []Scope +// Scope defines a single access scope. A access scope defines a permission +// for a user (that has the particular scope) to perform an operation, on a +// resource in a domain. +// +// Example: +// - `graph_node:owner:read` - defines a scope for the `graph_node` resource +// to perform `read` operation, on nodes that are in `owner` domain. +type Scope struct { + resource Resource + domain Domain + operation Operation } -func (s Scopes) Slice() []Scope { - return s.scopes +// NewScope creates a new scope. +func NewScope(resource Resource, domain Domain, operation Operation) *Scope { + return &Scope{ + resource: resource, + domain: domain, + operation: operation, + } } -func (r Role) Scopes() []Scope { - switch r { - case Owner: - return []Scope{ - GraphOwnerNodeRead, - GraphOwnerNodeWrite, - GraphMemberNodeRead, - GraphMemberNodeWrite, - GraphVisitorNodeRead, - GraphVisitorNodeWrite, - } - case Member: - return []Scope{ - GraphMemberNodeRead, - GraphMemberNodeWrite, - GraphVisitorNodeRead, - GraphVisitorNodeWrite, - } - case Viewer: - return []Scope{ - GraphMemberNodeRead, - GraphVisitorNodeRead, - } - case Visitor: - return []Scope{ - GraphVisitorNodeRead, - } - - default: - return []Scope{} +func (s *Scope) Satisfied(scopes []*Scope) error { + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.resource == s.resource || fs.resource == "*" + }, + ) + + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.domain == s.domain || fs.domain == "*" + }, + ) + + scopes = util.Filter( + scopes, + func(fs *Scope) bool { + return fs.operation == s.operation || fs.operation == "*" + }, + ) + + if len(scopes) == 0 { + return errors.Errorf("no scopes found for %s", s) } + + return nil } -// UnmarshalYAML implements yaml.Unmarshaler for Scopes to unmarshal either -// string or list of strings. -func (s *Scopes) UnmarshalYAML(unmarshal func(any) error) error { - err := unmarshal(&([]string{})) +// UnmarshalYAML implements yaml.Unmarshaler for Scope type to check for valid +// values. +func (s *Scope) UnmarshalYAML(unmarshal func(any) error) error { + var raw string + + err := unmarshal(&raw) if err != nil { + return errors.Wrap(err, "failed to YAML unmarshal scope") + } - var role Role + parts := strings.Split(raw, ":") + if len(parts) != 3 { + return errors.Errorf("invalid scope: %s", raw) + } - err = unmarshal(&role) - if err != nil { - return errors.Wrap(err, "failed to unmarshal scopes as role") - } + s.resource = Resource(parts[0]) + s.domain = Domain(parts[1]) + s.operation = Operation(parts[2]) - s.scopes = role.Scopes() + return nil +} - return nil - } +func (s Scope) String() string { + return strings.Join( + []string{ + string(s.resource), + string(s.domain), + string(s.operation), + }, + ":", + ) +} - scopes := []Scope{} +func (s Scope) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} - err = unmarshal(&scopes) +func (s *Scope) UnmarshalJSON(b []byte) error { + var raw string + + err := json.Unmarshal(b, &raw) if err != nil { - return errors.Wrap(err, "failed to unmarshal scopes as list") + return errors.Wrap(err, "failed to JSON unmarshal scope") } - s.scopes = scopes + parts := strings.Split(raw, ":") + if len(parts) != 3 { + return errors.Errorf("invalid scope: %s", raw) + } + + s.resource = Resource(parts[0]) + s.domain = Domain(parts[1]) + s.operation = Operation(parts[2]) return nil } -// UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid -// values. -func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { - var raw string +// Scopes defines a list of access scopes or a role. +// Can be unmarshaled from string - a role, defined in the auth configs roles +// map. +// Can be unmarshaled from list of strings - list of scopes. +type Scopes struct { + role Role + scopes []*Scope +} - err := unmarshal(&raw) - if err != nil { - return errors.Wrap(err, "failed to unmarshal role") +// Get returns a list of scopes, either from the role or slice of scopes. +func (s Scopes) Get(roles map[Role][]*Scope) []*Scope { + if len(s.scopes) > 0 { + return s.scopes } - role := Role(raw) - - switch role { - case Owner, Member, Viewer: - *r = role - - return nil - default: - return errors.Errorf("invalid role: %s", raw) + scopes, ok := roles[s.role] + if !ok { + return []*Scope{} } -} -// UnmarshalYAML implements yaml.Unmarshaler for Scope type to check for valid -// values. -func (s *Scope) UnmarshalYAML(unmarshal func(any) error) error { - var raw string + return scopes +} - err := unmarshal(&raw) +// UnmarshalYAML implements yaml.Unmarshaler for Scopes to unmarshal either +// string or list of strings. +func (s *Scopes) UnmarshalYAML(unmarshal func(any) error) error { + err := unmarshal(&([]string{})) if err != nil { - return errors.Wrap(err, "failed to unmarshal scope") + var role Role + + err = unmarshal(&role) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as role") + } + + s.role = role + s.scopes = []*Scope{} + + return nil } - scope := Scope(raw) + scopes := []*Scope{} - if !validScopes[scope] { - return errors.Errorf("invalid scope: %s", raw) + err = unmarshal(&scopes) + if err != nil { + return errors.Wrap(err, "failed to unmarshal scopes as list") } - *s = scope + s.scopes = scopes return nil } + +// // UnmarshalYAML implements yaml.Unmarshaler for Role type to check for valid +// // values. +// func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { +// var raw string +// +// err := unmarshal(&raw) +// if err != nil { +// return errors.Wrap(err, "failed to unmarshal role") +// } +// +// role := Role(raw) +// +// switch role { +// case Owner, Member, Viewer: +// *r = role +// +// return nil +// default: +// return errors.Errorf("invalid role: %s", raw) +// } +// } diff --git a/src/graph/services/auth/token.go b/src/graph/services/auth/token.go index 4d5028f..05a8d10 100644 --- a/src/graph/services/auth/token.go +++ b/src/graph/services/auth/token.go @@ -1,27 +1,32 @@ package auth import ( + "encoding/json" "time" "github.com/golang-jwt/jwt/v5" - "github.com/mitchellh/mapstructure" "github.com/pkg/errors" ) // Token defines data stored in JWT token. type Token struct { - Username string `mapstructure:"username"` - Expires int64 `mapstructure:"expires"` - Scopes []Scope `mapstructure:"scopes"` + Username string `json:"username"` + Expires int64 `json:"expires"` + Scopes []*Scope `json:"scopes"` } // FromMapClaims converts jwt.MapClaims to token claims. func FromMapClaims(mc jwt.MapClaims) (*Token, error) { t := &Token{} - err := mapstructure.Decode(mc, t) + b, err := json.Marshal(mc) if err != nil { - return nil, errors.Wrap(err, "failed to decode token claims") + return nil, errors.Wrap(err, "failed to marshal token claims") + } + + err = json.Unmarshal(b, t) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal token claims") } return t, nil diff --git a/src/graph/services/provider/access/access.go b/src/graph/services/provider/access/access.go index 295cfb5..f01c7c2 100644 --- a/src/graph/services/provider/access/access.go +++ b/src/graph/services/provider/access/access.go @@ -14,60 +14,219 @@ var _ graph.Provider = (*Provider)(nil) var ErrAccessDenied = errors.New("access denied") type Provider struct { - base graph.Provider - log *logr.LogR - role auth.Role + base graph.Provider + log *logr.LogR + scopes []*auth.Scope } // NewProvider creates a new filesystem graph provider. func NewProvider( base graph.Provider, log *logr.LogR, - role auth.Role, + scopes []*auth.Scope, ) *Provider { return &Provider{ - base: base, - log: log.Prefix("access"), - role: role, + base: base, + log: log.Prefix("access"), + scopes: scopes, } } // GetByID reads node by id, first checkint if role configured for provides // allows access to node. func (p *Provider) GetByID(id uuid.UUID) (*graph.Node, error) { + p.log.Debugf("GetByID %s", id.String()) + n, err := p.base.GetByID(id) if err != nil { return nil, errors.Wrap(err, "failed to get node by ID") } - // access, err := n.Access(p.base) - // if err != nil { - // return nil, errors.Wrap(err, "failed to get node access") - // } + domain, err := n.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) - // if !p.role.Allowed(access) { - // return nil, ErrAccessDenied - // } + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return nil, errors.Wrap(err, "scope requirement not satisfied") + } return n, nil } func (p *Provider) GetByPath(path pathutil.NodePath) (*graph.Node, error) { - return nil, nil + p.log.Debugf("GetByPath %s", path.String()) + + n, err := p.base.GetByPath(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + domain, err := n.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return nil, errors.Wrap(err, "scope requirement not satisfied") + } + + return n, nil } func (p *Provider) GetLeafs(path pathutil.NodePath) ([]*graph.Node, error) { - return nil, nil + p.log.Debugf("GetLeafs %s", path.String()) + + // check access to parent node + _, err := p.GetByPath(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + p.log.Debugf("base.GetLeafs %s", path.String()) + + leafs, err := p.base.GetLeafs(path) + if err != nil { + return nil, errors.Wrap(err, "failed to get node by path") + } + + p.log.Debugf( + "iterate leafs for node %q, len(%d)", path.String(), len(leafs), + ) + + var allowedLeafs []*graph.Node + + for _, leaf := range leafs { + domain, err := leaf.Domain(p.base) + if err != nil { + return nil, errors.Wrap(err, "failed to get node domain") + } + + p.log.Debugf("got domain %q for leaf %q", domain, leaf.Path) + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Read) + + p.log.Debugf("leaf %q requires scope %q", leaf.Path, requiredScope) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + p.log.Debugf("leaf %q not allowed: %s", leaf.Path, err.Error()) + + continue + } + + p.log.Debugf("leaf %q allowed", leaf.Path) + + allowedLeafs = append(allowedLeafs, leaf) + } + + return allowedLeafs, nil } func (p *Provider) Move(id uuid.UUID, path pathutil.NodePath) error { + p.log.Debugf("Move %s %s", id.String(), path.String()) + + n, err := p.base.GetByID(id) + if err != nil { + return errors.Wrap(err, "failed to get node by ID") + } + + domain, err := n.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + n, err = p.GetByPath(path.Parent()) + + domain, err = n.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope = auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Move(id, path) + if err != nil { + return errors.Wrap(err, "failed to move node") + } + return nil } +// TODO: write is accting like a create and update at the same time. this is +// not ideal and the interface should be separated into two methods. func (p *Provider) Write(n *graph.Node) error { + p.log.Debugf("Write %s", n.Path.String()) + + // curretly access is only checked for a create scenario, for which write + // has the only use case. this remains sub-optimal and should be fixes. + parent, err := p.base.GetByPath(n.Path.Parent()) + if err != nil { + return errors.Wrap(err, "failed to get parent node") + } + + domain, err := parent.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Write(n) + if err != nil { + return errors.Wrap(err, "failed to write node") + } + return nil } func (p *Provider) Delete(n *graph.Node) error { + p.log.Debugf("Delete %s", n.Path.String()) + + existing, err := p.base.GetByPath(n.Path) + if err != nil { + return errors.Wrap(err, "failed to get node by path") + } + + domain, err := existing.Domain(p.base) + if err != nil { + return errors.Wrap(err, "failed to get node domain") + } + + requiredScope := auth.NewScope(auth.GraphNode, domain, auth.Write) + + err = requiredScope.Satisfied(p.scopes) + if err != nil { + return errors.Wrap(err, "scope requirement not satisfied") + } + + err = p.base.Delete(n) + if err != nil { + return errors.Wrap(err, "failed to delete node") + } + return nil } diff --git a/src/graph/services/provider/root/root.go b/src/graph/services/provider/root/root.go index 3c0f7a0..7009bc4 100644 --- a/src/graph/services/provider/root/root.go +++ b/src/graph/services/provider/root/root.go @@ -4,6 +4,7 @@ import ( "github.com/gofrs/uuid" "github.com/pkg/errors" "rat/graph" + "rat/graph/services/auth" pathutil "rat/graph/util/path" ) @@ -20,6 +21,7 @@ var defaultConfig = Config{ //nolint:gochecknoglobals // constant. // Config contains root node provider configuration parameters. type Config struct { + Domain auth.Domain `yaml:"domain"` Content string `yaml:"content"` Template *graph.NodeTemplate `yaml:"template"` } @@ -108,6 +110,7 @@ func (p *Provider) rootNode() *graph.Node { return &graph.Node{ Header: graph.NodeHeader{ ID: uuid.Nil, + Domain: p.root.Domain, Template: p.root.Template, }, Content: p.root.Content, @@ -121,6 +124,10 @@ func (c *Config) fillDefaults() Config { fill := *c + if c.Domain == "" { + fill.Domain = defaultConfig.Domain + } + if c.Content == "" { fill.Content = defaultConfig.Content } diff --git a/src/graph/services/services.go b/src/graph/services/services.go index b3226e0..b24b9e2 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -39,11 +39,15 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { err error gs = &GraphServices{ URLResolver: urlresolve.NewResolver(c.URLResolver, log), - Auth: auth.NewTokenControl(c.Auth, log), log: log, } ) + gs.Auth, err = auth.NewTokenControl(c.Auth, log) + if err != nil { + return nil, errors.Wrap(err, "failed to create token control") + } + gs.Provider, err = provider.New(c.Provider, log) if err != nil { return nil, errors.Wrap(err, "failed to create graph provider") diff --git a/src/graph/util/path/path.go b/src/graph/util/path/path.go index db46901..570e2b1 100644 --- a/src/graph/util/path/path.go +++ b/src/graph/util/path/path.go @@ -21,7 +21,7 @@ func (p NodePath) Parent() NodePath { parts := p.Parts() if len(parts) < 2 { - return p + return "" } return NodePath(strings.Join(parts[:len(parts)-1], "/")) diff --git a/src/handler/authhttp/authhttp.go b/src/handler/authhttp/authhttp.go index e40a1bb..2afc364 100644 --- a/src/handler/authhttp/authhttp.go +++ b/src/handler/authhttp/authhttp.go @@ -1,12 +1,14 @@ package auth import ( + "context" "net/http" "strings" "github.com/gorilla/mux" "github.com/pkg/errors" "rat/graph/services" + "rat/graph/services/auth" "rat/handler/httputil" "rat/logr" ) @@ -99,7 +101,7 @@ func (h *handler) authMW(next http.Handler) http.Handler { ) } - _, err = h.gs.Auth.Validate(signed) + token, err := h.gs.Auth.Validate(signed) if err != nil { return httputil.Error( http.StatusUnauthorized, @@ -107,6 +109,10 @@ func (h *handler) authMW(next http.Handler) http.Handler { ) } + r = r.WithContext( + context.WithValue(r.Context(), auth.AuthTokenCtxKey, token), + ) + next.ServeHTTP(w, r) return nil diff --git a/src/handler/graphhttp/nodeshttp/nodeshttp.go b/src/handler/graphhttp/nodeshttp/nodeshttp.go index bb803e8..b929360 100644 --- a/src/handler/graphhttp/nodeshttp/nodeshttp.go +++ b/src/handler/graphhttp/nodeshttp/nodeshttp.go @@ -1,6 +1,7 @@ package nodeshttp import ( + "encoding/json" "net/http" "strings" @@ -11,6 +12,8 @@ import ( "rat/graph/render" "rat/graph/render/jsonast" "rat/graph/services" + "rat/graph/services/auth" + "rat/graph/services/provider/access" pathutil "rat/graph/util/path" "rat/handler/httputil" "rat/logr" @@ -31,6 +34,41 @@ type response struct { ChildNodes []*response `json:"childNodes,omitempty"` } +func accessWrapper( + base graph.Provider, + log *logr.LogR, + handler func( + p graph.Provider, w http.ResponseWriter, r *http.Request, + ) error, +) httputil.RatHandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + token, ok := r.Context().Value(auth.AuthTokenCtxKey).(*auth.Token) + if !ok || token == nil { + return httputil.Error( + http.StatusInternalServerError, + errors.New("failed to get auth token from context"), + ) + } + + b, err := json.MarshalIndent(token, "", " ") + if err != nil { + return httputil.Error( + http.StatusInternalServerError, + errors.Wrap(err, "failed to marshal token"), + ) + } + + log.Debugf("token:\n%s", string(b)) + + err = handler(access.NewProvider(base, log, token.Scopes), w, r) + if err != nil { + return err + } + + return nil + } +} + // RegisterRoutes registers graph routes on given router. func RegisterRoutes( router *mux.Router, log *logr.LogR, gs *services.GraphServices, @@ -58,8 +96,9 @@ func RegisterRoutes( log, "read"), ).Methods(http.MethodOptions) - nodeRouter.HandleFunc("", httputil.Wrap(h.read, log, "read")). - Methods(http.MethodGet) + nodeRouter.HandleFunc( + "", httputil.Wrap(accessWrapper(gs.Provider, log, h.read), log, "read"), + ).Methods(http.MethodGet) nodeRouter.HandleFunc("", httputil.Wrap(h.create, h.log, "create")). Methods(http.MethodPost) @@ -70,8 +109,10 @@ func RegisterRoutes( return nil } -func (h *handler) read(w http.ResponseWriter, r *http.Request) error { - n, err := h.getNode(r) +func (h *handler) read( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error { + n, err := getNode(p, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -81,13 +122,17 @@ func (h *handler) read(w http.ResponseWriter, r *http.Request) error { root := jsonast.NewRootAstPart("document") - h.r.Render(root, n, n.Content) + render.NewJSONRenderer(h.log, p).Render(root, n, n.Content) + + h.log.Debugf("rendered node %q", n.Path) - childNodes, err := h.getChildNodes(w, n) + childNodes, err := getChildNodes(p, h.log, w, n) if err != nil { return errors.Wrap(err, "failed to get child node paths") } + h.log.Debugf("got child nodes for node %q", n.Path) + err = httputil.WriteResponse( w, http.StatusOK, @@ -115,7 +160,7 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) error { return errors.Wrap(err, "failed to get body") } - n, err := h.getNode(r) + n, err := getNode(h.gs.Provider, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -154,7 +199,7 @@ func (h *handler) create(w http.ResponseWriter, r *http.Request) error { } func (h *handler) delete(w http.ResponseWriter, r *http.Request) error { - n, err := h.getNode(r) + n, err := getNode(h.gs.Provider, r) if err != nil { return httputil.Error( http.StatusInternalServerError, @@ -176,11 +221,13 @@ func (h *handler) delete(w http.ResponseWriter, r *http.Request) error { return nil } -func (h *handler) getChildNodes( +func getChildNodes( + p graph.Provider, + log *logr.LogR, w http.ResponseWriter, n *graph.Node, ) ([]*response, error) { - children, err := n.GetLeafs(h.gs.Provider) + children, err := n.GetLeafs(p) if err != nil { httputil.WriteError( w, @@ -196,7 +243,7 @@ func (h *handler) getChildNodes( for _, child := range children { root := jsonast.NewRootAstPart("document") - h.r.Render(root, child, child.Content) + render.NewJSONRenderer(log, p).Render(root, child, child.Content) childNodes = append( childNodes, @@ -213,10 +260,10 @@ func (h *handler) getChildNodes( return childNodes, nil } -func (h *handler) getNode(r *http.Request) (*graph.Node, error) { +func getNode(p graph.Provider, r *http.Request) (*graph.Node, error) { path := mux.Vars(r)["path"] - n, err := h.gs.Provider.GetByPath(pathutil.NodePath(path)) + n, err := p.GetByPath(pathutil.NodePath(path)) if err != nil { return nil, errors.Wrapf(err, "failed to get node %q", path) } From 841a395bde40cf3c125785b56a68d9ce3621c911 Mon Sep 17 00:00:00 2001 From: ruzv Date: Wed, 3 Jan 2024 20:00:16 +0200 Subject: [PATCH 7/8] rat-0006: Start API refactor --- src/graph/graph_test.go | 34 +++++++ src/graph/services/provider/access/access.go | 3 + src/graph/services/services.go | 8 +- src/handler/api/api.go | 26 ++++++ src/handler/api/router/router.go | 97 ++++++++++++++++++++ src/handler/fileshttp/fileshttp.go | 3 + src/handler/graphhttp/nodeshttp/nodeshttp.go | 23 ++++- src/handler/router/router.go | 8 +- src/note.md | 11 +++ 9 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/handler/api/api.go create mode 100644 src/handler/api/router/router.go create mode 100644 src/note.md diff --git a/src/graph/graph_test.go b/src/graph/graph_test.go index 333ac36..c282077 100644 --- a/src/graph/graph_test.go +++ b/src/graph/graph_test.go @@ -1,6 +1,7 @@ package graph import ( + pathutil "rat/graph/util/path" "testing" "github.com/google/go-cmp/cmp" @@ -56,3 +57,36 @@ func Test_parsePathName(t *testing.T) { }) } } + +func TestNode_Name(t *testing.T) { + t.Parallel() + + type fields struct { + Path pathutil.NodePath + Header NodeHeader + Content string + } + tests := []struct { + name string + fields fields + want string + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + n := &Node{ + Path: tt.fields.Path, + Header: tt.fields.Header, + Content: tt.fields.Content, + } + got := n.Name() + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("Node.Name() = %s", diff) + } + }) + } +} diff --git a/src/graph/services/provider/access/access.go b/src/graph/services/provider/access/access.go index f01c7c2..c20080b 100644 --- a/src/graph/services/provider/access/access.go +++ b/src/graph/services/provider/access/access.go @@ -19,6 +19,9 @@ type Provider struct { scopes []*auth.Scope } + + + // NewProvider creates a new filesystem graph provider. func NewProvider( base graph.Provider, diff --git a/src/graph/services/services.go b/src/graph/services/services.go index b24b9e2..d8f7b40 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -43,9 +43,11 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { } ) - gs.Auth, err = auth.NewTokenControl(c.Auth, log) - if err != nil { - return nil, errors.Wrap(err, "failed to create token control") + if c.Auth != nil { + gs.Auth, err = auth.NewTokenControl(c.Auth, log) + if err != nil { + return nil, errors.Wrap(err, "failed to create token control") + } } gs.Provider, err = provider.New(c.Provider, log) diff --git a/src/handler/api/api.go b/src/handler/api/api.go new file mode 100644 index 0000000..c3ae616 --- /dev/null +++ b/src/handler/api/api.go @@ -0,0 +1,26 @@ +package api + +import ( + "github.com/gorilla/mux" + "rat/logr" +) + +type Config struct{} + +type API struct { + log *logr.LogR + auth bool +} + +func NewAPI(config *Config, log *logr.LogR) (*API, error) { + log = log.Prefix("api") + + router := mux.NewRouter() + + return &API{ + log: log, + }, nil +} + +func (api *API) GraphHandler() { +} diff --git a/src/handler/api/router/router.go b/src/handler/api/router/router.go new file mode 100644 index 0000000..259d8c0 --- /dev/null +++ b/src/handler/api/router/router.go @@ -0,0 +1,97 @@ +package router + +import ( + "net/http" + + "github.com/gorilla/mux" + "rat/graph" + "rat/logr" +) + +// GraphHandlerFunc defines a handler function signature for HTTP request +// that request handler functons that interact with the graph. +type GraphHandlerFunc func( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error + +type Router struct { + log *logr.LogR + provider graph.Provider + muxRouter *mux.Router +} + +type Handler struct { + router *Router + method string + +} + +func NewRouter(log *logr.LogR, provider graph.Provider) (*Router, error) { + log = log.Prefix("router") + + // router := mux.NewRouter() + + return &Router{ + log: log, + provider: provider, + muxRouter: mux.NewRouter(), + }, nil +} + +func (r *Router) Path(path string) *Router { + newMuxRouter := r.muxRouter.PathPrefix(path).Subrouter() + + return &Router{ + + muxRouter: newMuxRouter, + } +} + +func (r *Router) Method(method string) *Handler { + return &Handler{ + router: r, + method: method, + } +} + +func (h *Handler) GraphHandler( + handler GraphHandlerFunc, +) { + r.router.HandleFunc(path, r.graphAccessWrapper(r.provider, r.log, handler)) +} + + +func (r *Router) graphAccessWrapper( + base graph.Provider, + log *logr.LogR, + handler GraphHandlerFunc, +) httputil.RatHandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + token, ok := r.Context().Value(auth.AuthTokenCtxKey).(*auth.Token) + if !ok || token == nil { + return httputil.Error( + http.StatusInternalServerError, + errors.New("failed to get auth token from context"), + ) + } + + b, err := json.MarshalIndent(token, "", " ") + if err != nil { + return httputil.Error( + http.StatusInternalServerError, + errors.Wrap(err, "failed to marshal token"), + ) + } + + log.Debugf("token:\n%s", string(b)) + + err = handler(access.NewProvider(base, log, token.Scopes), w, r) + if err != nil { + return err + } + + return nil + } +} + + diff --git a/src/handler/fileshttp/fileshttp.go b/src/handler/fileshttp/fileshttp.go index f34f988..e6afba1 100644 --- a/src/handler/fileshttp/fileshttp.go +++ b/src/handler/fileshttp/fileshttp.go @@ -13,6 +13,9 @@ import ( "rat/logr" ) +// requires graph +// + type handler struct { log *logr.LogR resolver *urlresolve.Resolver diff --git a/src/handler/graphhttp/nodeshttp/nodeshttp.go b/src/handler/graphhttp/nodeshttp/nodeshttp.go index b929360..f1327a5 100644 --- a/src/handler/graphhttp/nodeshttp/nodeshttp.go +++ b/src/handler/graphhttp/nodeshttp/nodeshttp.go @@ -34,12 +34,22 @@ type response struct { ChildNodes []*response `json:"childNodes,omitempty"` } + + +// GraphHandlerFunc defines a handler function for requests that interact with +// the graph. +type GraphHandlerFunc func( + p graph.Provider, w http.ResponseWriter, r *http.Request, +) error + +func (ghf GraphHandlerFunc) Handler() http.HandlerFunc { + return nil +} + func accessWrapper( base graph.Provider, log *logr.LogR, - handler func( - p graph.Provider, w http.ResponseWriter, r *http.Request, - ) error, + handler GraphHandlerFunc, ) httputil.RatHandlerFunc { return func(w http.ResponseWriter, r *http.Request) error { token, ok := r.Context().Value(auth.AuthTokenCtxKey).(*auth.Token) @@ -93,13 +103,18 @@ func RegisterRoutes( []string{http.MethodGet, http.MethodPost, http.MethodDelete}, []string{"Content-Type", "Authorization"}, ), - log, "read"), + log, + "read", + ), ).Methods(http.MethodOptions) nodeRouter.HandleFunc( "", httputil.Wrap(accessWrapper(gs.Provider, log, h.read), log, "read"), ).Methods(http.MethodGet) + // nodeRouter.HandleFunc("", GraphHandlerFunc(h.read).Handler()). + // Methods(http.MethodGet) + nodeRouter.HandleFunc("", httputil.Wrap(h.create, h.log, "create")). Methods(http.MethodPost) diff --git a/src/handler/router/router.go b/src/handler/router/router.go index 408a1cc..0dc35cd 100644 --- a/src/handler/router/router.go +++ b/src/handler/router/router.go @@ -81,7 +81,13 @@ func NewRouter( protectedRouter.HandleFunc("/test", func(w http.ResponseWriter, _ *http.Request) { - httputil.WriteError(w, http.StatusOK, "r we testing, huh?") + httputil.WriteResponse(w, http.StatusOK, + struct { + Message string `json:"message"` + }{ + Message: "r we testing, huh?", + }, + ) }, ) diff --git a/src/note.md b/src/note.md new file mode 100644 index 0000000..1303811 --- /dev/null +++ b/src/note.md @@ -0,0 +1,11 @@ + + + +- with auth + - with graph + - graphhttp + - nodehttp + - fileshttp +- without auth + - auth + - view (separate server) From d615fd734e0be5e1c67ee15dabb94e49e6496434 Mon Sep 17 00:00:00 2001 From: ruzv Date: Sat, 13 Jan 2024 21:35:13 +0200 Subject: [PATCH 8/8] rat-0006: Attempt api split off --- src/graph/services/services.go | 8 ++++ src/handler/api/api.go | 82 +++++++++++++++++++++++++++++--- src/handler/api/router/router.go | 54 +++++++++++---------- 3 files changed, 113 insertions(+), 31 deletions(-) diff --git a/src/graph/services/services.go b/src/graph/services/services.go index d8f7b40..4320c8d 100644 --- a/src/graph/services/services.go +++ b/src/graph/services/services.go @@ -10,6 +10,7 @@ import ( "rat/graph/services/provider" "rat/graph/services/urlresolve" "rat/graph/sync" + "rat/handler/api" "rat/logr" ) @@ -19,6 +20,7 @@ type Config struct { URLResolver *urlresolve.Config `yaml:"urlResolver"` Sync *sync.Config `yaml:"sync"` Auth *auth.Config `yaml:"auth"` + API *api.Config `yaml:"api"` } // GraphServices contains service components of a graph. @@ -28,6 +30,7 @@ type GraphServices struct { Index *index.GraphIndex URLResolver *urlresolve.Resolver Auth *auth.TokenControl + API *api.API log *logr.LogR } @@ -71,6 +74,11 @@ func NewGraphServices(c *Config, log *logr.LogR) (*GraphServices, error) { return nil, errors.Wrap(err, "failed to create index") } + gs.API, err = api.NewAPI(c.API, log) + if err != nil { + return nil, errors.Wrap(err, "failed to create api") + } + logMetrics(gs.Provider, log.Prefix("metrics")) return gs, nil diff --git a/src/handler/api/api.go b/src/handler/api/api.go index c3ae616..1c7b754 100644 --- a/src/handler/api/api.go +++ b/src/handler/api/api.go @@ -1,26 +1,94 @@ package api import ( - "github.com/gorilla/mux" + "fmt" + "net/http" + "time" + + "github.com/pkg/errors" + "rat/handler/api/router" "rat/logr" ) -type Config struct{} +var DefaultTimeouts = &Timeouts{ + Read: 15 * time.Second, + Write: 15 * time.Second, +} + +type Config struct { + Port int `yaml:"port" validate:"nonzero"` + Timeouts *Timeouts `yaml:"timeouts"` +} + +type Timeouts struct { + Read time.Duration `yaml:"read"` + Write time.Duration `yaml:"write"` +} type API struct { - log *logr.LogR - auth bool + log *logr.LogR + auth bool + config *Config + server *http.Server } func NewAPI(config *Config, log *logr.LogR) (*API, error) { log = log.Prefix("api") - router := mux.NewRouter() + r, err := router.NewRouter(log, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create router") + } + + r.Path("/graph") + + timeouts := config.Timeouts.FillDefaults() return &API{ - log: log, + log: log, + config: config, + + server: &http.Server{ + Handler: r, + Addr: fmt.Sprintf(":%d", config.Port), + WriteTimeout: timeouts.Write, + ReadTimeout: timeouts.Read, + }, }, nil } -func (api *API) GraphHandler() { +// Serve starts the rat server. Blocks. +func (api *API) Serve(exit chan error) { + api.log.Infof("serving on http://localhost:%d", api.config.Port) + + start := time.Now() + + err := api.server.ListenAndServe() + if err != nil { + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + } + + r.log.Infof("uptime: %s", time.Since(start).String()) + + exit <- errors.Wrap(err, "listen and serve error") +} + +func (t *Timeouts) FillDefaults() *Timeouts { + if t == nil { + return DefaultTimeouts + } + + fill := *t + + if fill.Read == 0 { + fill.Read = DefaultTimeouts.Read + } + + if fill.Write == 0 { + fill.Write = DefaultTimeouts.Write + } + + return &fill } diff --git a/src/handler/api/router/router.go b/src/handler/api/router/router.go index 259d8c0..bc61aba 100644 --- a/src/handler/api/router/router.go +++ b/src/handler/api/router/router.go @@ -1,29 +1,33 @@ package router import ( + "encoding/json" "net/http" "github.com/gorilla/mux" + "github.com/pkg/errors" "rat/graph" + "rat/graph/services/auth" + "rat/graph/services/provider/access" + "rat/handler/httputil" "rat/logr" ) // GraphHandlerFunc defines a handler function signature for HTTP request -// that request handler functons that interact with the graph. +// that request handler functions that interact with the graph. type GraphHandlerFunc func( p graph.Provider, w http.ResponseWriter, r *http.Request, ) error type Router struct { - log *logr.LogR - provider graph.Provider - muxRouter *mux.Router + log *logr.LogR + provider graph.Provider + muxRouter *mux.Router } type Handler struct { - router *Router - method string - + router *Router + method string } func NewRouter(log *logr.LogR, provider graph.Provider) (*Router, error) { @@ -32,34 +36,38 @@ func NewRouter(log *logr.LogR, provider graph.Provider) (*Router, error) { // router := mux.NewRouter() return &Router{ - log: log, - provider: provider, - muxRouter: mux.NewRouter(), + log: log, + provider: provider, + muxRouter: mux.NewRouter(), }, nil } func (r *Router) Path(path string) *Router { - newMuxRouter := r.muxRouter.PathPrefix(path).Subrouter() - - return &Router{ + newMuxRouter := r.muxRouter.PathPrefix(path).Subrouter() - muxRouter: newMuxRouter, - } + return &Router{ + muxRouter: newMuxRouter, + } } -func (r *Router) Method(method string) *Handler { - return &Handler{ - router: r, - method: method, - } +func (r *Router) Handler(method string) *Handler { + return &Handler{ + router: r, + method: method, + } } func (h *Handler) GraphHandler( - handler GraphHandlerFunc, + handler GraphHandlerFunc, ) { - r.router.HandleFunc(path, r.graphAccessWrapper(r.provider, r.log, handler)) + r.router.HandleFunc(path, r.graphAccessWrapper(r.provider, r.log, handler)) } +var _ http.Handler = (*Router)(nil) + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.muxRouter.ServeHTTP(w, req) +} func (r *Router) graphAccessWrapper( base graph.Provider, @@ -93,5 +101,3 @@ func (r *Router) graphAccessWrapper( return nil } } - -